Compare commits

..

25 Commits

Author SHA1 Message Date
undergroundwires
af7219f6e1 Fix tree node check states not being updated
This commit fixes a bug where the check states of tree nodes were not
correctly updated upon selecting predefined groups like "Standard",
"Strict", "None" and "All".

It resolves the issue by manually triggering of updates for mutated
array containing selected scripts.

It enhances test coverage to prevent regression and verify the correct
behavior of state updates.

This bug was introduced in commit
4995e49c46, which optimized reactivity by
removing deep state tracking.
2023-11-07 01:14:38 +01:00
undergroundwires
8ccaec7af6 Fix unresponsive copy button on instructions modal
This commit fixes the bug where the "Copy" button does not copy when
clicked on download instructions modal (on Linux and macOS).

This commit also introduces several improvements to the UI components
related to copy action and their interaction with the clipboard feature.

It adds more tests to avoid regression of the bugs and improves
maintainability, testability and adherence to Vue's reactive principles.

Changes include:

- Fix non-responsive copy button in the download instructions modal by
  triggering a `click` event in `AppIcon.vue`.
- Improve `TheCodeButtons.vue`:
  - Remove redundant `getCurrentCode` function.
  - Separate components for each button for better separation of
    concerns and higher testability.
  - Use the `gap` property in the flexbox layout, replacing the less
    explicit sibling combinator approach.
- Add `useClipboard` compositional hook for more idiomatic Vue approach
  to interacting with the clipboard.
- Add `useCurrentCode` compositional hook to handle current code state
  more effectively with unified logic.
- Abstract clipboard operations to an interface to isolate
  responsibilities.
- Switch clipboard implementation to the `navigator.clipboard` API,
  moving away from the deprecated `document.execCommand`.
- Move clipboard logic to the presentation layer to conform to
  separation of concerns and domain-driven design principles.
- Improve `IconButton.vue` component to increase reusability with
  consistent sizing.
2023-11-06 21:55:43 +01:00
undergroundwires
b2ffc90da7 Add winget download instructions 2023-11-05 17:05:17 +01:00
undergroundwires-bot
72e4d0b896 ⬆️ bump everywhere to 0.12.6 2023-11-04 12:14:46 +00:00
undergroundwires
5bb13e34f8 win: fix store revert for multiple installs #260
This commit improves the revert script for store apps to handle
scenarios where `Get-AppxPackage` returns multiple packages. Instead of
relying on a single package result, the script now iterates over all
found packages and attempts installation using the `AppxManifest.xml`
for each. This ensures that even if multiple versions or instances of a
package are found, the script will robustly handle and attempt to install
each one until successful.

Other changes:

- Add better message with suggestion if the revert code fails, as
  discussed in #270.
- Improve robustness of finding manifest path by using `Join-Path`
  instead of basic string concatenation. This resolves wrong paths being
  built due to missing `\` in file path.
- Add check for null or empty `InstallLocation` before accessing
  manifest path. It prevents errors when accessing `AppxManifest.xml`,
  enhancing script robustness and reliability.
- Improve error handling in manifest file existence check with try-catch
  block to catch and log exceptions, ensuring uninterrupted script
  execution in edge cases such as when the script lacks access to read
  the directory.
- Add verification of package installation before attempting to install
  the package for increased robustness.
- Add documentation for revertCode.
2023-11-03 15:24:15 +01:00
undergroundwires
0466b86f10 win, linux: unify & improve Firefox clean-up #273
This commit unifies some of the logic, documentation and naming for
Firefox clean-up with improvements on both Linux and Windows platforms.

Windows:

- 'Clear browsing history and cache':
  - Not recommend.
  - Align script name and logic with Linux implementation.
  - New documentation and not including the script in recommendation
    provides safety against unintended data loss as discussed in #273.
- 'Clear Firefox user profiles, settings, and data':
  - Rename to 'Clear all Firefox user information and preferences' for
    improved clarity.
  - Add more documentation.

Linux:

- Replace `DeleteFromFirefoxProfiles` with
  `DeleteFilesFromFirefoxProfiles`.
- Migrate implementation to Python:
  - Add more user-friendly outputs.
  - Exclude removing directory itself for additional safety.

Both Linux and Windows:

- Improve documentation for:
  - 'Clear Firefox user profiles, settings, and data'
  - 'Clear Firefox history'
2023-11-02 13:18:54 +01:00
undergroundwires
ca81f68ff1 Migrate to Vue 3.0 #230
- Migrate from "Vue 2.X" to "Vue 3.X"
- Migrate from "Vue Test Utils v1" to "Vue Test Utils v2"

Changes in detail:

- Change `inserted` to `mounted`.
- Change `::v-deep` to `:deep`.
- Change to Vue 3.0 `v-modal` syntax.
- Remove old Vue 2.0 transition name, keep the ones for Vue 3.0.
- Use new global mounting API `createApp`.
- Change `destroy` to `unmount`.
- Bootstrapping:
  - Move `provide`s for global dependencies to a bootsrapper from
    `App.vue`.
  - Remove `productionTip` setting (not in Vue 3).
  - Change `IVueBootstrapper` for simplicity and Vue 3 compatible API.
  - Add missing tests.
- Remove `.text` access on `VNode` as it's now internal API of Vue.
- Import `CSSProperties` from `vue` instead of `jsx` package.
- Shims:
  - Remove unused `shims-tsx.d.ts`.
  - Remove `shims-vue.d.ts` that's missing in quickstart template.
- Unit tests:
  - Remove old typing workaround for mounting components.
  - Rename `propsData` to `props`.
  - Remove unneeded `any` cast workarounds.
  - Move stubs and `provide`s under `global` object.

Other changes:

- Add `dmg-license` dependency explicitly due to failing electron builds
  on macOS (electron-userland/electron-builder#6520,
  electron-userland/electron-builder#6489). This was a side-effect of
  updating dependencies for this commit.
2023-11-01 13:39:39 +01:00
undergroundwires
4995e49c46 Improve UI performance by optimizing reactivity
- Replace `ref`s with `shallowRef` when deep reactivity is not needed.
- Replace `readonly`s with `shallowReadonly` where the goal is to only
  prevent `.value` mutation.
- Remove redundant `ref` in `SizeObserver.vue`.
- Remove redundant nested `ref` in `TooltipWrapper.vue`.
- Remove redundant `events` export from `UseCollectionState.ts`.
- Remove redundant `computed` from `UseCollectionState.ts`.
- Remove `timestamp` from `TreeViewFilterEvent` that becomes unnecessary
  after using `shallowRef`.
- Add missing unit tests for `UseTreeViewFilterEvent`.
- Add missing stub for `FilterChangeDetails`.
2023-10-31 13:57:57 +01:00
undergroundwires
77123d8c92 win: change system app removal to hard delete #260
This commit changes the system app removal functionality in privacy.sexy
to perform a hard delete, while preserving the soft-delete logic for
handling residual files.

It improves in-code documentation to facilitate a clearer understanding
of the code execution flow, as the logic for removing system apps has
grown in complexity and length.

Transitioning to a hard-delete approach resolves issues related to
residual links to soft-deleted apps:

- Resolves issue with Edge remaining in the installed apps list (#236).
- Resolves issue with Edge remaining in the programs list (#194).
- Resolves issue with Edge shortcuts persisting in the start menu (#73).

Other changes:

- `RunPowerShell`:
  - Introduce `codeComment` and `revertCodeComment` parameters for
    improved in-code documentation.
- `CommentCode`:
  - Simplify naming to `Comment`.
  - Rename `comment` to `codeComment` for clarity.
  - Add functionality to comment on revert with the `revertCodeComment`
    parameter.
2023-10-30 12:39:10 +01:00
undergroundwires
e72c1c13ea win: improve file delete
This commit unifies the way the files are being deleted by introducing
the `DeleteFiles` function. It refactors existing scripts that are
deleting files to use the new function, to improve their documentation
and increase their safety.

Script changes:

- 'Clear Software Reporter Tool logs':
  - Rename to: 'Clear Google's "Software Reporter Tool" logs'
- 'Clear credentials in Windows Credential Manager':
  - Migrate code to PowerShell, removing the need to delete files.
  - Improve error messages and robustness of the implementation.
- 'Clear Nvidia residual telemetry files':
  - Extract to two scripts for more granularity and better
    documentation:
      1. 'Disable Nvidia telemetry components'
      2. 'Disable Nvidia telemetry drivers'
  - Change the logic so instead of clearing directory contents and
    deleting drivers, it conducts a soft delete for reversibility to
    prioritize user safety.
- 'Remove OneDrive residual files':
  - Improve documentation
- 'Clear primary Windows telemetry file':
  - Rename to 'Clear diagnostics tracking logs'.
  - Add missing file paths seen on modern versions of Windows.
  - Add more documentation.
- 'Clear Windows Update History (`WUAgent`) system logs':
  - Rename to 'Clear Windows update files'.
  - Add more documentation.
- 'Clear Cryptographic Services diagnostic traces':
  - Rename to 'Clear "Cryptographic Services" diagnostic traces'.
  - Add more documentation.

Other changes:

- Improve `DeleteGlob`:
  - Add iteration callbacks for its reusability.
  - Improve its documentation.
  - Make recursion optional.
  - Improve sanity check (verification) logic for given glob when
    granting permissions.
  - Fix granting permissions using wrong variable to find out parent
    directory.
- Improve `IterateGlob`:
  - Use `Get-Item` to get results. This fixes `DeleteDirectory` not
    being able to delete directory itself but just its contents.
  - Introduce and use `recurse` parameter to provide optional recursive
    search logic (to search in subdirectories) using `Get-ChildItem`.
  - Fix wrong PowerShell syntax for `$revert` variable value for
    `revertCode`: replace `true` with `$true`.
  - Order iterated paths based on their length to process the deepest
    item first.
  - Improve handling of missing files with correct/informative outputs
    when granting permissions.
- Improve `SoftDeleteFiles`:
  - Introduce and use `recurse` parameter for explicitness.
  - Fix undefined `$backupFilePath` by replacing it with correct
    `$originalFilePath`.
  - Improve documentation.
- Ensure consistent use of instructive language in code comments.
2023-10-29 18:42:41 +01:00
undergroundwires
e775d68a9b linux: fix string formatting of Firefox configs
This commit fixes some configurations being set wrong values to wrong
YAML notation used for string values.
2023-10-28 13:58:41 +02:00
undergroundwires
f8e5f1a5a2 Fix incorrect tooltip position after window resize
This commit fixes an issue where the tooltip position becomes inaccurate
after resizing the window.

The solution uses `autoUpdate` functionality of `floating-ui` to update
the position automatically on resize events. This function depends on
browser APIs: `IntersectionObserver` and `ResizeObserver`. The official
documentation recommends polyfilling those to support old browsers.

Polyfilling `ResizeObserver` is already part of the codebase, used by
`SizeObserver.vue`. This commit refactors polyfill logic to be reusable
across different components, and reuses it on `TooltipWrapper.vue`.

Polyfilling `IntersectionObserver` is ignored due to this API being
older and more widely supported.
2023-10-27 20:58:07 +02:00
undergroundwires
f4a74f058d win: improve soft file/app delete security #260
This commit improves soft file delete logic:

- Unify logic for soft deleting single files and system apps.
- Rename `RenameSystemFile` templating function to `SoftDeleteFiles` so
  new name gives clarity to:
   - It's not necessarily single file being renamed but can be multiple
     files.
   - It's not necessarily system files being renamed, but can also work
     without granting extra permissions.
- Grant permissions for only files that will be backed up, skipping
  unnecessarily granting permissions to folders/other files. Both
  `SeRestorePrivilege` and `SeTakeownershipPrivileges` are claimed and
  revoked as necessary.
- Make granting permissions optional through `grantPermissions`
  parameter. Do not take permissions if not needed.
- Restore permissions to system default after file is renamed. Before
  both deletion of system apps and renaming system files did not restore
  their original permissions. This might leave user computers
  vulnerable, which is fixed in this commit. It ensures that the
  system's original security posture is preserved.
- Deleting system apps is now independent of `Get-AppxPackage`,
  improving its robustness and enabling their execution once system apps
  are hard-deleted (#260)
- Introduce common way to share glob iteration logic of how the
  directories are being cleaned up. It reuses most of the logic from
  former `DeleteGlob` with some improvements:
  - Simplify call to `Get-ChildItem` by avoiding `-Filter` parameter.
  - Improve reliability of getting parent directory in `DeleteGlob`
    sanity check to use .NET's `[System.IO.Path]` methods.
2023-10-26 18:35:39 +02:00
undergroundwires
80821fca07 Fix compiler failing with nested with expression
The previous implementation of `WithParser` used regex, which struggles
with parsing nested structures correctly. This commit improves
`WithParser` to track and parse all nested `with` expressions.

Other improvements:

- Throw meaningful errors when syntax is wrong. Replacing the prior
  behavior of silently ignoring such issues.
- Remove `I` prefix from related interfaces to align with newer code
  conventions.
- Add more unit tests for `with` expression.
- Improve documentation for templating.
- `ExpressionRegexBuilder`:
  - Use words `capture` and `match` correctly.
  - Fix minor issues revealed by new and improved tests:
     - Change regex for matching anything except surrounding
       whitespaces. The new regex ensures that it works even without
       having any preceeding text.
     - Change regex for capturing pipelines. The old regex was only
       matching (non-greedy) first character of the pipeline in tests,
       new regex matches the full pipeline.
- `ExpressionRegexBuilder.spec.ts`:
  - Ensure consistent way to define `describe` and `it` blocks.
  - Replace `expectRegex` tests, regex expectations test internal
    behavior of the class, not the external.
  - Simplified tests by eliminating the need for UUID suffixes/prefixes.
2023-10-25 19:39:12 +02:00
undergroundwires
dfd4451561 win: improve script environment robustness #221
This commit ensures the script functions as expected, even when invoked
from unexpected environments.

Using `setlocal` initializes a distinct environment for privacy.sexy.
It's strategically placed after the admin privilege check to avoid
unnecessary setup in case of a relaunch. The script concludes with
`endlocal` right before the exit, maintaining a clean environment
throughout its execution and ensuring no unintentional global
environment modifications.

Changes:

- Enhance script's environment robustness.
- Add descriptive comments for script start/end sequences.
2023-10-24 16:56:54 +02:00
undergroundwires
8570b02dde win: prevent updates from reinstalling apps #260
This commit addresses the issue of unwanted applications being
reinstalled during a Windows update. By adding a specific registry
entry, this commit ensures that Windows apps, once removed, do not
return with subsequent updates.

This change ensures more control over the applications present on a
Windows system, particularly after an update, enhancing user experience
and systeam cleanliness.
2023-10-23 16:52:52 +02:00
undergroundwires
d6da406c61 Centralize Electron entry file path configuration
This commit refactors configuration to use centrally defined Electron
entry file path to improve maintainability and reduce duplication.

- Replace the hardcoded file path in the `main` field of `package.json`
  with a reference to the `ELECTRON_ENTRY` environment variable, managed
  by `electron-vite`.
- Update `electron-vite` to version 1.0.28, enabling the use of
  `ELECTRON_ENTRY` environment variable feature (details in
  alex8088/electron-vite#270).
2023-10-22 15:03:58 +02:00
undergroundwires
060e789662 win: improve directory cleanup security
This commit improves the security, reliability, and robustness of
directory cleanup operations on Windows.

The focus is shifted from deleting entire directories to purging their
contents, addressing potential unintended side effects. Previously,
numerous directories were removed, which could destabilize system
behavior.

This improvement has crucial security implications. The prior approach
involved changing ownership and assigning permissions to the directory
itself, leading to an altered and potentially less secure OS security
posture.

Directory removal improvements include:

- Output user-friendly messages.
- Improved ownership and permission handling for file deletion.
- Explicit shared functions for enhanced reliability/security.
- Centralized way to delete glob (wildcard) patterns in Windows.
Notable script improvements:

- 'Clear Steam dumps, logs, and traces':
  - Convert the script to a category to provide more granularity.
  - Improve cache cleaning, ensuring the entire cache directory is
    cleared, not just the log files.
- 'Clear "Temporary Internet Files" (browser cache)':
  - Add more documentation.
  - Grant necessary permissions to folders, fixing errors due to
    lack of permissions before.
- 'Clear Windows Update Medic Service logs':
  - Remove redundant permission grants, as they are unnecessary in
    recent Windows versions.
- 'Clear Server-initiated Healing Events system logs',
  'Clear Windows Update events logs':
  - Merge due to identical functionalities.
  - Add more documentation.
- 'Clear Defender scan (protection) history':
  - Remove the execution with `TrustedInstallerPrivileges`, uniformly
    using `grantPermissions` as with other scripts. This addresses the
    false-positive alerts from Microsoft Defender, as discussed in #264.
- 'Clear "Temporary Internet Files" (browser cache)':
  - Retain `INetCache` and `Temporary Internet Files` directories,
    purging only their contents. This approach aims to resolve the issue
    mentioned in #145, where the absence of these folders could prevent
    Microsoft Office applications from launching.
2023-10-21 17:41:37 +02:00
undergroundwires
e40b9a3cf5 win: fix Microsoft Advertising app removal #200
This commit fixes the issue where the Microsoft Advertising app fails to
be removed using the standard script. The problem is due to Microsoft
Advertising SDK (`Microsoft.Advertising.Xaml`) acting as a framework
package. Such packages are automatically installed when a specific
application requires them, and they cannot be individually removed if
there are applications that depend on them. The only way to effectively
remove this library is by uninstalling the dependent applications.

Key findings:

- On Windows 11 22H2, the issue does not arise as the package does not
  exist.
- On Windows 10 22H2, the user is prompted to delete dependent
  applications, like MSN Weather and Mail And Calendar apps. Once these
  apps are removed, the Microsoft Advertising app is automatically
  removed.

Given the nuances and potential for confusion, this script is removed.
This means that the app will no longer be removed directly but will be
handled as part of the removal of its dependencies.
2023-10-20 16:04:25 +02:00
undergroundwires
237d9944f9 Fix YAML error for site release in CI/CD
Fix the syntax error in the GitHub action script that was caused by
improper multi-line YAML notation. This correction ensures the action
can successfully parse and execute.
2023-10-19 12:33:26 +02:00
undergroundwires
79b46bf210 Improve performance of rendering during search
Optimize the tree view rendering during searches by enhancing the render
queue ordering. This update changes the rendering order to prioritize
visible nodes, leading to faster appearance of these nodes during
searches. The ordering logic now ignores the depth in the hierarchy and
instead focused on the node order. The collapsed check for the node
itself is removed, ensuring that visible collapsed parents are first
while their invisible children are rendered later.
2023-10-18 16:44:49 +02:00
undergroundwires
98a26f9ae4 win: improve system app uninstall /w fallback #260
This commit improves soft deletion of system apps. Before if the package
was missing, it failed to recover or delete system apps. Now, it works
even though if `Get-AppxPackage` returns null (i.e. package is
non-existing), so it can be executed even after a hard delete. This
allows safely introducing hard-delete of system apps (as discussed in
 #260) with still keeping a robust soft-delete as complement.

Before, the script was dependent on `Get-AppxPackage.InstallLocation`,
however a system app can only be located in one of these folders:

- C:\Windows\SystemApps\{PackageFamilyName}
- C:\Windows\{ShortAppName}

To ensure resilience, this commit adjust the script to rename the files
within these directories if Get-AppxPackage fails, this provides a
fallback.
2023-10-17 13:56:32 +02:00
undergroundwires
dbe3c5cfb9 win: improve system app uninstall cleanup #73
- Add documentation about folders.
- Add more user-friendly logging.
- Continue uninstallation if single folder fails (remove throw).
- Continue uninstallation if renaming single file fails.
- Add handling of `Metadata` folder as suggested in #73.
2023-10-16 18:42:29 +02:00
undergroundwires
25d7f7b2a4 Bump dependencies to latest
This commit updates various dependencies to their latest versions.

Other changes include:

- Moved the following from `devDependencies` to `dependencies`:
  - `electron-log`
  - `electron-updater`
- Remove `npm` dependency.
- Code changes:
  - Add type casting in several places to align with the latest
    `typescript` version.
  - Adopt to new return type of `setTimeout`.
- Dependencies not upgraded due to
  `@vue/eslint-config-airbnb-with-typescript` not supporting
  `@eslint-typescript` V6 (see vuejs/eslint-config-airbnb#58):
  - `vue/eslint-config-typescript`
  - `@typescript-eslint/eslint-plugin`
  - `@typescript-eslint/parser`
- Enable video recording for cypress as it's disabled by default since
  13.X.X.
2023-10-16 02:06:19 +02:00
undergroundwires-bot
b76e99ac0f ⬆️ bump everywhere to 0.12.5 2023-10-14 14:17:25 +00:00
144 changed files with 8863 additions and 8290 deletions

View File

@@ -3,6 +3,6 @@ runs:
steps:
-
name: Setup node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 16.x

View File

@@ -21,7 +21,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node
@@ -49,7 +49,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node
@@ -78,7 +78,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Install Docker on macOS
if: matrix.os == 'macos' # macOS runner is missing Docker

View File

@@ -15,7 +15,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node

View File

@@ -10,7 +10,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node

View File

@@ -18,7 +18,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node

View File

@@ -14,7 +14,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node
@@ -42,7 +42,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node

View File

@@ -13,7 +13,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node

View File

@@ -25,7 +25,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
-
name: Initialize CodeQL
uses: github/codeql-action/init@v2

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ${{ matrix.os }}-latest
steps:
-
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
ref: master # otherwise it defaults to the version tag missing bump commit
fetch-depth: 0 # fetch all history

View File

@@ -10,7 +10,7 @@ jobs:
steps:
-
name: "Infrastructure: Checkout"
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
path: aws
repository: undergroundwires/aws-static-site-with-cd
@@ -75,7 +75,7 @@ jobs:
working-directory: aws
-
name: "App: Checkout"
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
path: app
ref: master # otherwise we don't get version bump commit
@@ -102,7 +102,7 @@ jobs:
-
name: "App: Deploy to S3"
shell: bash
run: >-
run: |-
declare web_output_dir
if ! web_output_dir=$(cd app && node scripts/print-dist-dir.js --web); then
echo 'Error: Could not determine distribution directory.'

View File

@@ -14,7 +14,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node

View File

@@ -16,7 +16,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Setup node
uses: ./.github/actions/setup-node

View File

@@ -14,7 +14,7 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
-
name: Set-up node
uses: ./.github/actions/setup-node

View File

@@ -1,5 +1,51 @@
# Changelog
## 0.12.6 (2023-11-03)
* Bump dependencies to latest | [25d7f7b](https://github.com/undergroundwires/privacy.sexy/commit/25d7f7b2a479e51e092881cc2751e67a7d3f179f)
* win: improve system app uninstall cleanup #73 | [dbe3c5c](https://github.com/undergroundwires/privacy.sexy/commit/dbe3c5cfb91ba8a1657838b69117858843c8fbc8)
* win: improve system app uninstall /w fallback #260 | [98a26f9](https://github.com/undergroundwires/privacy.sexy/commit/98a26f9ae47af2668aa53f39d1768983036048ce)
* Improve performance of rendering during search | [79b46bf](https://github.com/undergroundwires/privacy.sexy/commit/79b46bf21004d96d31551439e5db5d698a3f71f3)
* Fix YAML error for site release in CI/CD | [237d994](https://github.com/undergroundwires/privacy.sexy/commit/237d9944f900f5172366868d75219224ff0542b0)
* win: fix Microsoft Advertising app removal #200 | [e40b9a3](https://github.com/undergroundwires/privacy.sexy/commit/e40b9a3cf53c341f2e84023a9f0e9680ac08f3fa)
* win: improve directory cleanup security | [060e789](https://github.com/undergroundwires/privacy.sexy/commit/060e7896624309aebd25e8b190c127282de177e8)
* Centralize Electron entry file path configuration | [d6da406](https://github.com/undergroundwires/privacy.sexy/commit/d6da406c61e5b9f5408851d1302d6d7398157a2e)
* win: prevent updates from reinstalling apps #260 | [8570b02](https://github.com/undergroundwires/privacy.sexy/commit/8570b02dde14ffad64863f614682c3fc1f87b6c2)
* win: improve script environment robustness #221 | [dfd4451](https://github.com/undergroundwires/privacy.sexy/commit/dfd44515613f38abe5a806bda36f44e7b715b50b)
* Fix compiler failing with nested `with` expression | [80821fc](https://github.com/undergroundwires/privacy.sexy/commit/80821fca0769e5fd2c6338918fbdcea12fbe83d2)
* win: improve soft file/app delete security #260 | [f4a74f0](https://github.com/undergroundwires/privacy.sexy/commit/f4a74f058db9b5bcbcbe438785db5ec88ecc1657)
* Fix incorrect tooltip position after window resize | [f8e5f1a](https://github.com/undergroundwires/privacy.sexy/commit/f8e5f1a5a2afa1f18567e6d965359b6a1f082367)
* linux: fix string formatting of Firefox configs | [e775d68](https://github.com/undergroundwires/privacy.sexy/commit/e775d68a9b4a5f9e893ff0e3500dade036185193)
* win: improve file delete | [e72c1c1](https://github.com/undergroundwires/privacy.sexy/commit/e72c1c13ea2d73ebfc7a8da5a21254fdfc0e5b59)
* win: change system app removal to hard delete #260 | [77123d8](https://github.com/undergroundwires/privacy.sexy/commit/77123d8c929d23676a9cb21d7b697703fd1b6e82)
* Improve UI performance by optimizing reactivity | [4995e49](https://github.com/undergroundwires/privacy.sexy/commit/4995e49c469211404dac9fcb79b75eb121f80bce)
* Migrate to Vue 3.0 #230 | [ca81f68](https://github.com/undergroundwires/privacy.sexy/commit/ca81f68ff1c3bbe5b22981096ae9220b0b5851c7)
* win, linux: unify & improve Firefox clean-up #273 | [0466b86](https://github.com/undergroundwires/privacy.sexy/commit/0466b86f1013341c966a9bbf6513990337b16598)
* win: fix store revert for multiple installs #260 | [5bb13e3](https://github.com/undergroundwires/privacy.sexy/commit/5bb13e34f8de2e2a7ba943ff72b12c0569435e62)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.5...0.12.6)
## 0.12.5 (2023-10-13)
* Fix Docker build and improve checks #220 | [7669985](https://github.com/undergroundwires/privacy.sexy/commit/7669985f8e1446e726a95626ecf35b3ce6b60a16)
* Add SAST security checks with SECURITY.md #178 | [3e5239f](https://github.com/undergroundwires/privacy.sexy/commit/3e5239f7d35e57749c01adf3dbbcd365aebb39c8)
* Add Scoop download instructions #174 | [cf55ca9](https://github.com/undergroundwires/privacy.sexy/commit/cf55ca9e28b064fa7a516077a9da23e3a8e3f534)
* win: fix and improve temp dir cleanup #176, #89 | [d457504](https://github.com/undergroundwires/privacy.sexy/commit/d45750428cca010daf2721b33a8ae3a01b28813b)
* win, linux: improve VSCode setting robustness #196 | [e8a52f7](https://github.com/undergroundwires/privacy.sexy/commit/e8a52f717dc799b34ceeb1c27c2b8219391dff6a)
* linux: fix obsolete Firefox DPI script #239 | [e5f6edf](https://github.com/undergroundwires/privacy.sexy/commit/e5f6edf405bcec7c29ea4d7932d1910620fa15f8)
* win: add removal of Edge assocations #64 | [888c916](https://github.com/undergroundwires/privacy.sexy/commit/888c9166fc66a2094137fa8be739cc21bafef5f6)
* win: improve Edge & OneDrive shortcut removal #73 | [8501495](https://github.com/undergroundwires/privacy.sexy/commit/8501495c170af61913288a63dbd369db5bbc5003)
* win: relocate and document SecHealthUI #190 | [2862951](https://github.com/undergroundwires/privacy.sexy/commit/286295128d0179358e0c6b7b6415d752175a1aed)
* Add developer toolkit UI component | [2147eae](https://github.com/undergroundwires/privacy.sexy/commit/2147eae687b82d05bc43bb4605d9068f148bb92a)
* win: fix and improve network data usage reset #265 | [5e359c2](https://github.com/undergroundwires/privacy.sexy/commit/5e359c2fb82a08e6acf7159b70ca86a8234b359b)
* win: improve app reversion and docs #260 | [a3f11df](https://github.com/undergroundwires/privacy.sexy/commit/a3f11dff187c821a00910c20dac05e285cda9073)
* Fix working directory in CI/CD web release | [698b570](https://github.com/undergroundwires/privacy.sexy/commit/698b570ee6e300d6703015464f4345b5e706f1cb)
* Implement new UI component for icons #230 | [48730bc](https://github.com/undergroundwires/privacy.sexy/commit/48730bca0506120bca4bf3a23545d59f2b1a9009)
* win: fix and improve AppCompat disabling #255 | [bab6316](https://github.com/undergroundwires/privacy.sexy/commit/bab6316e7625230cf4a4cf67c3aca417347db75c)
* win, linux, mac: fix typos and improve naming | [67c3677](https://github.com/undergroundwires/privacy.sexy/commit/67c3677621b201525a813e8a26f07d607176e89b)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.4...0.12.5)
## 0.12.4 (2023-09-25)
* win: fix Windows spotlight revert, docs, recommend | [659fea7](https://github.com/undergroundwires/privacy.sexy/commit/659fea7afcabcd0ea273cfdcc8c4bae190c126f3)

View File

@@ -43,6 +43,7 @@ You have two alternatives:
1. [Create an issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose) and ask for someone else to add the script for you.
2. Or send a PR yourself. This would make it faster to get your code into the project. You need to add scripts to related OS in [collections](src/application/collections/) folder. Then you'd sent a pull request, see [pull request process](#pull-request-process).
- 💡 You should use existing shared functions for most of the operations, like `DisableService` for disabling services, to maintain code consistency and efficiency.
- 📖 If you're unsure about the syntax, check [collection-files.md](docs/collection-files.md).
- 📖 If you wish to use templates, use [templating.md](./docs/templating.md).

View File

@@ -122,7 +122,7 @@
## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.4/privacy.sexy-Setup-0.12.4.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.4/privacy.sexy-0.12.4.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.4/privacy.sexy-0.12.4.AppImage). For more options, see [here](#additional-install-options).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-Setup-0.12.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.AppImage). For more options, see [here](#additional-install-options).
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
@@ -153,12 +153,21 @@ Online version does not require to run any software on your computer. Offline ve
## Additional Install Options
- Check the [releases page](https://github.com/undergroundwires/privacy.sexy/releases) for all available versions.
- Using [Scoop](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) package manager on Windows:
- Other unofficial channels (not maintained by privacy.sexy) for Windows include:
- [Scoop 🥄](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) (latest version):
```powershell
scoop bucket add extras
scoop install privacy.sexy
```
```powershell
scoop bucket add extras
scoop install privacy.sexy
```
- [winget 🪟](https://winget.run/pkg/undergroundwires/privacy.sexy) (may be outdated):
```powershell
winget install -e --id undergroundwires.privacy.sexy
```
With winget, updates require manual submission; the auto-update feature within privacy.sexy will notify you of new releases post-installation.
## Development

View File

@@ -6,7 +6,10 @@ const CYPRESS_BASE_DIR = 'tests/e2e/';
export default defineConfig({
fixturesFolder: `${CYPRESS_BASE_DIR}/fixtures`,
screenshotsFolder: `${CYPRESS_BASE_DIR}/screenshots`,
video: true,
videosFolder: `${CYPRESS_BASE_DIR}/videos`,
e2e: {
baseUrl: `http://localhost:${ViteConfig.server.port}/`,
specPattern: `${CYPRESS_BASE_DIR}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}

View File

@@ -2,79 +2,142 @@
## Benefits of templating
- Generating scripts by sharing code to increase best-practice usage and maintainability.
- Creating self-contained scripts without cross-dependencies.
- Use of pipes for writing cleaner code and letting pipes do dirty work.
- **Code sharing:** Share code across scripts for consistent practices and easier maintenance.
- **Script independence:** Generate self-contained scripts, eliminating the need for external code.
- **Cleaner code:** Use pipes for complex operations, resulting in more readable and streamlined code.
## Expressions
- Expressions start and end with mustaches (double brackets, `{{` and `}}`).
- E.g. `Hello {{ $name }} !`
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) but not the same.
- Functions enables usage of expressions.
- In script definition parts of a function, see [`Function`](./collection-files.md#Function).
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
- Expressions inside expressions (nested templates) are supported.
- An expression can output another expression that will also be compiled.
- E.g. following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output.
**Syntax:**
```go
{{ with $condition }}
echo {{ $text }}
{{ end }}
```
Expressions are enclosed within `{{` and `}}`.
Example: `Hello {{ $name }}!`.
They are a core component of templating, enhancing scripts with dynamic capabilities and functionality.
**Syntax similarity:**
The syntax shares similarities with [Go Templates ❤️](https://pkg.go.dev/text/template), but with some differences:
**Function definitions:**
You can use expressions in function definition.
Refer to [Function](./collection-files.md#function) for more details.
Example usage:
```yaml
name: GreetFunction
parameters:
- name: name
code: Hello {{ $name }}!
```
If you assign `name` the value `world`, invoking `GreetFunction` would result in `Hello world!`.
**Function arguments:**
You can also use expressions in arguments in nested function calls.
Refer to [`Function | collection-files.md`](./collection-files.md#functioncall) for more details.
Example with nested function calls:
```yaml
-
name: PrintMessageFunction
parameters:
- name: message
code: echo "{{ $message }}"
-
name: GreetUserFunction
parameters:
- name: userName
call:
name: PrintMessageFunction
parameters:
argument: 'Hello, {{ $userName }}!'
```
Here, if `userName` is `Alice`, invoking `GreetUserFunction` would execute `echo "Hello, Alice!"`.
**Nested templates:**
You can nest expressions inside expressions (also called "nested templates").
This means that an expression can output another expression where compiler will compile both.
For example, following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output:
```go
{{ with $condition }}
echo {{ $text }}
{{ end }}
```
### Parameter substitution
A simple function example:
Parameter substitution dynamically replaces variable references with their corresponding values in the script.
**Example function:**
```yaml
function: EchoArgument
name: DisplayTextFunction
parameters:
- name: 'argument'
code: Hello {{ $argument }} !
- name: 'text'
code: echo {{ $text }}
```
It would print "Hello world" if it's called in a [script](./collection-files.md#script) as following:
```yaml
script: Echo script
call:
function: EchoArgument
parameters:
argument: World
```
A function can call other functions such as:
```yaml
-
function: CallerFunction
parameters:
- name: 'value'
call:
function: EchoArgument
parameters:
argument: {{ $value }}
-
function: EchoArgument
parameters:
- name: 'argument'
code: Hello {{ $argument }} !
```
Invoking `DisplayTextFunction` with `text` set to `"Hello, world!"` would result in `echo "Hello, World!"`.
### with
Skips its "block" if the variable is absent or empty. Its "block" is between `with` start (`{{ with .. }}`) and end (`{{ end }`}) expressions.
E.g. `{{ with $parameterName }} Hi, I'm a block! {{ end }}` would only output `Hi, I'm a block!` if `parameterName` has any value..
The `with` expression enables conditional rendering and provides a context variable for simpler code.
It binds its context (value of the provided parameter value) as arbitrary `.` value. It allows you to use the argument value of the given parameter when it is provided and not empty such as:
**Optional block rendering:**
If the provided variable is falsy (`false`, `null`, or empty), the compiler skips the enclosed block of code.
A "block" lies between the with start (`{{ with .. }}`) and end (`{{ end }}`) expressions, defining its boundaries.
Example:
```go
{{ with $optionalVariable }}
Hello
{{ end }}
```
This would display `Hello` if `$optionalVariable` is truthy.
**Parameter declaration:**
You should set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
Declare parameters used for `with` condition as optional such as:
```yaml
name: ConditionalOutputFunction
parameters:
- name: 'data'
optional: true
code: |-
{{ with $data }}
Data is: {{ . }}
{{ end }}
```
**Context variable:**
`with` statement binds its context (value of the provided parameter value) as arbitrary `.` value.
`{{ . }}` syntax gives you access to the context variable.
This is optional to use, and not required to use `with` expressions.
For example:
```go
{{ with $parameterName }}Parameter value is {{ . }} here {{ end }}
```
It supports multiline text inside the block. You can have something like:
**Multiline text:**
It supports multiline text inside the block. You can write something like:
```go
{{ with $argument }}
@@ -83,7 +146,9 @@ It supports multiline text inside the block. You can have something like:
{{ end }}
```
You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution):
**Inner expressions:**
You can also embed other expressions inside its block, such as [parameter substitution](#parameter-substitution):
```go
{{ with $condition }}
@@ -91,32 +156,44 @@ You can also use other expressions inside its block, such as [parameter substitu
{{ end }}
```
💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
This also includes nesting `with` statements:
Example:
```yaml
function: FunctionThatOutputsConditionally
parameters:
- name: 'argument'
optional: true
code: |-
{{ with $argument }}
Value is: {{ . }}
```go
{{ with $condition1 }}
Value of $condition1: {{ . }}
{{ with $condition2 }}
Value of $condition2: {{ . }}
{{ end }}
{{ end }}
```
### Pipes
- Pipes are functions available for handling text.
- Allows stacking actions one after another also known as "chaining".
- Like [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), the concept is simple: each pipeline's output becomes the input of the following pipe.
- You cannot create pipes. [A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
- You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
- ❗ Pipe names must be camelCase without any space or special characters.
- **Existing pipes**
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
- `escapeDoubleQuotes`: Escapes `"` characters, allows you to use them inside double quotes (`"`).
- **Example usages**
- `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}`
- `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}`
Pipes are functions designed for text manipulation.
They allow for a sequential application of operations resembling [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), also known as "chaining".
Each pipeline's output becomes the input of the following pipe.
**Pre-defined**:
Pipes are pre-defined by the system.
You cannot create pipes in [collection files](./collection-files.md).
[A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
**Compatibility:**
You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
For example:
```go
{{ with $script }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}
```
**Naming:**
❗ Pipe names must be camelCase without any space or special characters.
**Available pipes:**
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
- `escapeDoubleQuotes`: Escapes `"` characters for batch command execution, allows you to use them inside double quotes (`"`).

View File

@@ -8,15 +8,21 @@ import distDirs from './dist-dirs.json' assert { type: 'json' };
const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts');
const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts');
const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html');
const DIST_DIR = resolvePathFromProjectRoot(distDirs.electronUnbundled);
const ELECTRON_DIST_SUBDIRECTORIES = {
main: resolveElectronDistSubdirectory('main'),
preload: resolveElectronDistSubdirectory('preload'),
renderer: resolveElectronDistSubdirectory('renderer'),
};
process.env.ELECTRON_ENTRY = resolve(ELECTRON_DIST_SUBDIRECTORIES.main, 'index.cjs');
export default defineConfig({
main: getSharedElectronConfig({
distDirSubfolder: 'main',
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.main,
entryFilePath: MAIN_ENTRY_FILE,
}),
preload: getSharedElectronConfig({
distDirSubfolder: 'preload',
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.preload,
entryFilePath: PRELOAD_ENTRY_FILE,
}),
renderer: mergeConfig(
@@ -25,7 +31,7 @@ export default defineConfig({
}),
{
build: {
outDir: resolve(DIST_DIR, 'renderer'),
outDir: ELECTRON_DIST_SUBDIRECTORIES.renderer,
rollupOptions: {
input: {
index: WEB_INDEX_HTML_PATH,
@@ -42,7 +48,7 @@ function getSharedElectronConfig(options: {
}): UserConfig {
return {
build: {
outDir: resolve(DIST_DIR, options.distDirSubfolder),
outDir: options.distDirSubfolder,
lib: {
entry: options.entryFilePath,
},
@@ -64,6 +70,11 @@ function getSharedElectronConfig(options: {
};
}
function resolvePathFromProjectRoot(pathSegment: string) {
function resolvePathFromProjectRoot(pathSegment: string): string {
return resolve(__dirname, pathSegment);
}
function resolveElectronDistSubdirectory(subDirectory: string): string {
const electronDistDir = resolvePathFromProjectRoot(distDirs.electronUnbundled);
return resolve(electronDistDir, subDirectory);
}

10439
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,11 @@
{
"name": "privacy.sexy",
"version": "0.12.4",
"version": "0.12.6",
"private": true,
"slogan": "Now you have the choice",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
"author": "undergroundwires",
"type": "module",
"main": "./dist-electron-unbundled/main/index.cjs",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
@@ -36,71 +35,73 @@
"dependencies": {
"@floating-ui/vue": "^1.0.2",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.23.4",
"ace-builds": "^1.30.0",
"cross-fetch": "^4.0.0",
"electron-log": "^4.4.8",
"electron-progressbar": "^2.1.0",
"electron-updater": "^6.1.4",
"file-saver": "^2.0.5",
"markdown-it": "^13.0.1",
"npm": "^9.8.1",
"vue": "^2.7.14"
"markdown-it": "^13.0.2",
"vue": "^3.3.7"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@rushstack/eslint-patch": "^1.3.2",
"@types/ace": "^0.0.48",
"@rushstack/eslint-patch": "^1.5.1",
"@types/ace": "^0.0.49",
"@types/file-saver": "^2.0.5",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-legacy": "^4.1.1",
"@vitejs/plugin-vue2": "^2.2.0",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^1.3.6",
"autoprefixer": "^10.4.15",
"cypress": "^12.17.2",
"electron": "^25.3.2",
"electron-builder": "^24.6.3",
"@vue/test-utils": "^2.4.1",
"autoprefixer": "^10.4.16",
"cypress": "^13.3.1",
"electron": "^27.0.0",
"electron-builder": "^24.6.4",
"electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1",
"electron-log": "^4.4.8",
"electron-updater": "^6.1.4",
"electron-vite": "^1.0.27",
"eslint": "^8.46.0",
"eslint-plugin-cypress": "^2.14.0",
"eslint-plugin-vue": "^9.6.0",
"eslint-plugin-vuejs-accessibility": "^1.2.0",
"icon-gen": "^3.0.1",
"electron-vite": "^1.0.28",
"eslint": "^8.51.0",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.17.0",
"eslint-plugin-vuejs-accessibility": "^2.2.0",
"icon-gen": "^4.0.0",
"jsdom": "^22.1.0",
"markdownlint-cli": "^0.35.0",
"postcss": "^8.4.28",
"remark-cli": "^11.0.0",
"markdownlint-cli": "^0.37.0",
"postcss": "^8.4.31",
"remark-cli": "^12.0.0",
"remark-lint-no-dead-urls": "^1.1.0",
"remark-preset-lint-consistent": "^5.1.2",
"remark-validate-links": "^12.1.1",
"sass": "^1.64.1",
"start-server-and-test": "^2.0.0",
"remark-validate-links": "^13.0.0",
"sass": "^1.69.3",
"start-server-and-test": "^2.0.1",
"svgexport": "^0.4.2",
"terser": "^5.19.2",
"tslib": "~2.4.0",
"typescript": "~4.6.2",
"vite": "^4.4.9",
"vitest": "^0.34.2",
"vue-tsc": "^1.8.8",
"terser": "^5.21.0",
"tslib": "^2.6.2",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vitest": "^0.34.6",
"vue-tsc": "^1.8.19",
"yaml-lint": "^1.7.0"
},
"//devDependencies": {
"terser": "Used by @vitejs/plugin-legacy for minification",
"typescript": [
"Cannot upgrade to 5.X.X due to unmaintained @vue/cli-plugin-typescript, https://github.com/vuejs/vue-cli/issues/7401",
"Cannot upgrade to > 4.6.X otherwise unit tests do not work, https://github.com/evanw/node-source-map-support/issues/252"
],
"tslib": "Cannot upgrade to > 2.4.X otherwise unit tests do not work, https://github.com/evanw/node-source-map-support/issues/252",
"@typescript-eslint/eslint-plugin": "Cannot upgrade to 6.X.X due to @vue/eslint-config-typescript, https://github.com/vuejs/eslint-config-typescript/pull/60",
"@typescript-eslint/parser": "Cannot upgrade to 6.X.X due to @vue/eslint-config-typescript, https://github.com/vuejs/eslint-config-typescript/pull/60"
"@rushstack/eslint-patch": "Needed by @vue/eslint-config-typescript",
"@vue/eslint-config-typescript": "Cannot upgrade to 12.X.X due to @vue/eslint-config-airbnb-with-typescript, https://github.com/vuejs/eslint-config-airbnb/issues/58",
"@typescript-eslint/eslint-plugin": "Cannot upgrade to 6.X.X due to @vue/eslint-config-airbnb-with-typescript, https://github.com/vuejs/eslint-config-airbnb/issues/58",
"@typescript-eslint/parser": "Cannot upgrade to 6.X.X due to @vue/eslint-config-airbnb-with-typescript, https://github.com/vuejs/eslint-config-airbnb/issues/58"
},
"homepage": "https://privacy.sexy",
"repository": {
"type": "git",
"url": "https://github.com/undergroundwires/privacy.sexy.git"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
},
"//optionalDependencies": {
"dmg-license": "Required by `electron-builder` for DMG builds on macOS, https://github.com/electron-userland/electron-builder/issues/6489, https://github.com/electron-userland/electron-builder/issues/6520"
}
}

View File

@@ -14,45 +14,44 @@ export class ExpressionRegexBuilder {
.addRawRegex('\\s+');
}
public matchPipeline() {
public captureOptionalPipeline() {
return this
.expectZeroOrMoreWhitespaces()
.addRawRegex('(\\|\\s*.+?)?');
.addRawRegex('((?:\\|\\s*\\b[a-zA-Z]+\\b\\s*)*)');
}
public matchUntilFirstWhitespace() {
public captureUntilWhitespaceOrPipe() {
return this
.addRawRegex('([^|\\s]+)');
}
public matchMultilineAnythingExceptSurroundingWhitespaces() {
public captureMultilineAnythingExceptSurroundingWhitespaces() {
return this
.expectZeroOrMoreWhitespaces()
.addRawRegex('([\\S\\s]+?)')
.expectZeroOrMoreWhitespaces();
.expectOptionalWhitespaces()
.addRawRegex('([\\s\\S]*\\S)')
.expectOptionalWhitespaces();
}
public expectExpressionStart() {
return this
.expectCharacters('{{')
.expectZeroOrMoreWhitespaces();
.expectOptionalWhitespaces();
}
public expectExpressionEnd() {
return this
.expectZeroOrMoreWhitespaces()
.expectOptionalWhitespaces()
.expectCharacters('}}');
}
public expectOptionalWhitespaces() {
return this
.addRawRegex('\\s*');
}
public buildRegExp(): RegExp {
return new RegExp(this.parts.join(''), 'g');
}
private expectZeroOrMoreWhitespaces() {
return this
.addRawRegex('\\s*');
}
private addRawRegex(regex: string) {
this.parts.push(regex);
return this;

View File

@@ -6,8 +6,9 @@ export class ParameterSubstitutionParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder()
.expectExpressionStart()
.expectCharacters('$')
.matchUntilFirstWhitespace() // First match: Parameter name
.matchPipeline() // Second match: Pipeline
.captureUntilWhitespaceOrPipe() // First capture: Parameter name
.expectOptionalWhitespaces()
.captureOptionalPipeline() // Second capture: Pipeline
.expectExpressionEnd()
.buildRegExp();

View File

@@ -1,59 +1,222 @@
// eslint-disable-next-line max-classes-per-file
import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { IExpression } from '../Expression/IExpression';
import { ExpressionPosition } from '../Expression/ExpressionPosition';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class WithParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder()
// {{ with $parameterName }}
.expectExpressionStart()
.expectCharacters('with')
.expectOneOrMoreWhitespaces()
.expectCharacters('$')
.matchUntilFirstWhitespace() // First match: parameter name
.expectExpressionEnd()
// ...
.matchMultilineAnythingExceptSurroundingWhitespaces() // Second match: Scope text
// {{ end }}
.expectExpressionStart()
.expectCharacters('end')
.expectExpressionEnd()
.buildRegExp();
export class WithParser implements IExpressionParser {
public findExpressions(code: string): IExpression[] {
if (!code) {
throw new Error('missing code');
}
return parseWithExpressions(code);
}
}
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1];
const scopeText = match[2];
enum WithStatementType {
Start,
End,
ContextVariable,
}
type WithStatement = {
readonly type: WithStatementType.Start;
readonly parameterName: string;
readonly position: ExpressionPosition;
} | {
readonly type: WithStatementType.End;
readonly position: ExpressionPosition;
} | {
readonly type: WithStatementType.ContextVariable;
readonly position: ExpressionPosition;
readonly pipeline: string | undefined;
};
function parseAllWithExpressions(
input: string,
): WithStatement[] {
const expressions = new Array<WithStatement>();
for (const match of input.matchAll(WithStatementStartRegEx)) {
expressions.push({
type: WithStatementType.Start,
parameterName: match[1],
position: createPosition(match),
});
}
for (const match of input.matchAll(WithStatementEndRegEx)) {
expressions.push({
type: WithStatementType.End,
position: createPosition(match),
});
}
for (const match of input.matchAll(ContextVariableWithPipelineRegEx)) {
expressions.push({
type: WithStatementType.ContextVariable,
position: createPosition(match),
pipeline: match[1],
});
}
return expressions;
}
function createPosition(match: RegExpMatchArray): ExpressionPosition {
const startPos = match.index;
const endPos = startPos + match[0].length;
return new ExpressionPosition(startPos, endPos);
}
class WithStatementBuilder {
private readonly contextVariables = new Array<{
readonly positionInScope: ExpressionPosition;
readonly pipeline: string | undefined;
}>();
public addContextVariable(
absolutePosition: ExpressionPosition,
pipeline: string | undefined,
): void {
const positionInScope = new ExpressionPosition(
absolutePosition.start - this.startExpressionPosition.end,
absolutePosition.end - this.startExpressionPosition.end,
);
this.contextVariables.push({
positionInScope,
pipeline,
});
}
public buildExpression(endExpressionPosition: ExpressionPosition, input: string): IExpression {
const parameters = new FunctionParameterCollection();
parameters.addParameter(new FunctionParameter(this.parameterName, true));
const position = new ExpressionPosition(
this.startExpressionPosition.start,
endExpressionPosition.end,
);
const scope = input.substring(this.startExpressionPosition.end, endExpressionPosition.start);
return {
parameters: [new FunctionParameter(parameterName, true)],
evaluator: (context) => {
const argumentValue = context.args.hasArgument(parameterName)
? context.args.getArgument(parameterName).argumentValue
parameters,
position,
evaluate: (context) => {
const argumentValue = context.args.hasArgument(this.parameterName)
? context.args.getArgument(this.parameterName).argumentValue
: undefined;
if (!argumentValue) {
return '';
}
return replaceEachScopeSubstitution(scopeText, (pipeline) => {
const substitutedScope = this.substituteContextVariables(scope, (pipeline) => {
if (!pipeline) {
return argumentValue;
}
return context.pipelineCompiler.compile(argumentValue, pipeline);
});
return substitutedScope;
},
};
}
constructor(
private readonly startExpressionPosition: ExpressionPosition,
private readonly parameterName: string,
) {
}
private substituteContextVariables(
scope: string,
substituter: (pipeline: string) => string,
): string {
if (!this.contextVariables.length) {
return scope;
}
let substitutedScope = '';
let scopeSubstrIndex = 0;
for (const contextVariable of this.contextVariables) {
substitutedScope += scope.substring(scopeSubstrIndex, contextVariable.positionInScope.start);
substitutedScope += substituter(contextVariable.pipeline);
scopeSubstrIndex = contextVariable.positionInScope.end;
}
substitutedScope += scope.substring(scopeSubstrIndex, scope.length);
return substitutedScope;
}
}
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
function buildErrorContext(code: string, statements: readonly WithStatement[]): string {
const formattedStatements = statements.map((s) => `- [${s.position.start}, ${s.position.end}] ${WithStatementType[s.type]}`).join('\n');
return [
'Code:', '---', code, '---',
'nStatements:', '---', formattedStatements, '---',
].join('\n');
}
function parseWithExpressions(input: string): IExpression[] {
const allStatements = parseAllWithExpressions(input);
const sortedStatements = allStatements
.slice()
.sort((a, b) => b.position.start - a.position.start);
const expressions = new Array<IExpression>();
const builders = new Array<WithStatementBuilder>();
const throwWithContext = (message: string) => {
throw new Error(`${message}\n${buildErrorContext(input, allStatements)}}`);
};
while (sortedStatements.length > 0) {
const statement = sortedStatements.pop();
if (!statement) {
break;
}
switch (statement.type) { // eslint-disable-line default-case
case WithStatementType.Start:
builders.push(new WithStatementBuilder(
statement.position,
statement.parameterName,
));
break;
case WithStatementType.ContextVariable:
if (builders.length === 0) {
throwWithContext('Context variable before `with` statement.');
}
builders[builders.length - 1].addContextVariable(statement.position, statement.pipeline);
break;
case WithStatementType.End:
if (builders.length === 0) {
throwWithContext('Redundant `end` statement, missing `with`?');
}
expressions.push(builders.pop().buildExpression(statement.position, input));
break;
}
}
if (builders.length > 0) {
throwWithContext('Missing `end` statement, forgot `{{ end }}?');
}
return expressions;
}
const ContextVariableWithPipelineRegEx = new ExpressionRegexBuilder()
// {{ . | pipeName }}
.expectExpressionStart()
.expectCharacters('.')
.matchPipeline() // First match: pipeline
.expectOptionalWhitespaces()
.captureOptionalPipeline() // First capture: pipeline
.expectExpressionEnd()
.buildRegExp();
function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) {
// Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets,
// but instead letting the pipeline compiler to fail on those.
return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => {
return replacer(match1);
});
}
const WithStatementStartRegEx = new ExpressionRegexBuilder()
// {{ with $parameterName }}
.expectExpressionStart()
.expectCharacters('with')
.expectOneOrMoreWhitespaces()
.expectCharacters('$')
.captureUntilWhitespaceOrPipe() // First capture: parameter name
.expectExpressionEnd()
.expectOptionalWhitespaces()
.buildRegExp();
const WithStatementEndRegEx = new ExpressionRegexBuilder()
// {{ end }}
.expectOptionalWhitespaces()
.expectExpressionStart()
.expectCharacters('end')
.expectOptionalWhitespaces()
.expectExpressionEnd()
.buildRegExp();

View File

@@ -707,15 +707,9 @@ actions:
-
category: Clear Firefox history
docs: |-
Mozilla Firefox, or simply Firefox, is a free and open-source web browser developed by the Mozilla Foundation and
its subsidiary the Mozilla Corporation [1].
This category encompasses a series of scripts aimed at helping users manage and delete their browsing history and related data in Mozilla Firefox.
Firefox stores user-related data in user profiles [2].
See also [the Firefox homepage](https://web.archive.org/web/20221029214632/https://www.mozilla.org/en-US/firefox/).
[1]: https://web.archive.org/web/20221029145113/https://en.wikipedia.org/wiki/Firefox "Firefox | Wikipedia | en.wikipedia.org"
[2]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
The scripts are designed to target different aspects of user data stored by Firefox, providing users options for maintaining privacy and freeing up disk space.
children:
-
name: Clear Firefox cache
@@ -755,9 +749,13 @@ actions:
# Snap installation
rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/*
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: crashes/
pathGlob: crashes/*
-
function: DeleteFilesFromFirefoxProfiles
parameters:
pathGlob: crashes/events/*
-
name: Clear Firefox cookies
docs: |-
@@ -765,41 +763,37 @@ actions:
[1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
call:
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: cookies.sqlite
pathGlob: cookies.sqlite
-
name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.)
# This script (name, documentation and code) is same in Linux and Windows collections.
# Changes should be done at both places.
# Marked: refactor-with-partials
docs: |-
The file "places.sqlite" stores the annotations, bookmarks, favorite icons, input history, keywords, and browsing history (a record of visited pages) [1].
The tables include [1]:
- `moz_anno_attributes`: Annotation Attributes
- `moz_annos`: Annotations
- `moz_bookmarks`: Bookmarks
- `moz_bookmarks_roots`: Bookmark roots i.e. places, menu, toolbar, tags, unfiled
- `moz_favicons`: Favorite icons - including URL of icon
- `moz_historyvisits`: A history of the number of times a site has been visited
- `moz_inputhistory`: A history of URLs typed by the user
- `moz_items_annos`: Item annotations
- `moz_keywords`: Keywords
- `moz_places`: Places/Sites visited - referenced by `moz_historyvisits`
URL data is stored in the `moz_places` table. However, this table is connected to `moz_annos`, `moz_bookmarks`, and `moz_inputhistory` and `moz_historyvisits`.
As these entries are connected to each other, we'll delete all of them at the same time [2].
This script targets the Firefox browsing history, including URLs, downloads, bookmarks, and site visits, by deleting specific database entries.
**Bookmarks**:
Firefox bookmarks are stored in tables such as `moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots` [3].
There are also not very well documented tables, such as `moz_bookmarks_deleted` [4].
Firefox stores various user data in a file named `places.sqlite`. This file includes:
**Downloads:**
Firefox downloads are stored in the 'places.sqlite' database, within the 'moz_annos' table [5].
The entries in `moz_annos` are linked to `moz_places` that store the actual history entry (`moz_places.id = moz_annos.place_id`) [6].
Associated URL information is stored within the 'moz_places' table [5].
Downloads have been historically stored in `downloads.rdf` for Firefox 2.x and below [7].
Starting with Firefox 3.x they're stored in `downloads.sqlite` [7].
- Annotations, bookmarks, and favorite icons (`moz_anno_attributes`, `moz_annos`, `moz_favicons`) [1]
- Browsing history, a record of pages visited (`moz_places`, `moz_historyvisits`) [1]
- Keywords and typed URLs (`moz_keywords`, `moz_inputhistory`) [1]
- Item annotations (`moz_items_annos`) [1]
- Bookmark roots such as places, menu, toolbar, tags, unfiled (`moz_bookmarks_roots`) [1]
**Favicons:**
Firefox favicons are stored in the `favicons.sqlite` database, within the `moz_icons` table [5].
Older versions of Firefox stored Favicons in the 'places.sqlite' database, within the `moz_favicons` table [5].
The `moz_places` table holds URL data, connecting to various other tables like `moz_annos`, `moz_bookmarks`, `moz_inputhistory`, and `moz_historyvisits` [2].
Due to these connections, the script removes entries from all relevant tables simultaneously to maintain database integrity.
**Bookmarks**: Stored across several tables (`moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots`) [3], with additional undocumented tables like `moz_bookmarks_deleted` [4].
**Downloads**: Stored in the 'places.sqlite' database, within the 'moz_annos' table [5]. The entries in `moz_annos` are linked to `moz_places` that store the actual history entry
(`moz_places.id = moz_annos.place_id`) [6]. Associated URL information is stored within the 'moz_places' table [5]. Downloads have been historically stored in `downloads.rdf` for Firefox 2.x
and below [7], and `downloads.sqlite` later on [7].
**Favicons**: Older Firefox versions stored favicons in `places.sqlite` within the `moz_favicons` table [5], while newer versions use `favicons.sqlite` and the `moz_icons` table [5].
By executing this script, users can ensure their Firefox browsing history, bookmarks, and downloads are thoroughly removed, contributing to a cleaner and more private browsing experience.
[1]: https://web.archive.org/web/20221029141626/https://kb.mozillazine.org/Places.sqlite "Places.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
[2]: https://web.archive.org/web/20221030160803/https://wiki.mozilla.org/images/0/08/Places.sqlite.schema.pdf "Places.sqlite.schema.pdf | Mozilla Wiki"
@@ -810,21 +804,21 @@ actions:
[7]: https://web.archive.org/web/20221029145712/https://kb.mozillazine.org/Downloads.rdf "Downloads.rdf | MozillaZine Knowledge Base | kb.mozillazine.org"
call:
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: downloads.rdf
pathGlob: downloads.rdf
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: downloads.sqlite
pathGlob: downloads.sqlite
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: places.sqlite
pathGlob: places.sqlite
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: favicons.sqlite
pathGlob: favicons.sqlite
-
name: Clear Firefox logins
docs: |-
@@ -837,17 +831,17 @@ actions:
[2]: https://web.archive.org/web/20221029145757/https://bugzilla.mozilla.org/show_bug.cgi?id=1593467 "1593467 - Automatically restore from logins-backup.json when logins.json is missing or corrupt | Bugzilla | mozilla.org | bugzilla.mozilla.org"
call:
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: logins.json
pathGlob: logins.json
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: logins-backup.json
pathGlob: logins-backup.json
-
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: signons.sqlite
pathGlob: signons.sqlite
-
name: Clear Firefox autocomplete history
docs: |-
@@ -856,9 +850,9 @@ actions:
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call:
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: formhistory.sqlite
pathGlob: formhistory.sqlite
-
name: Clear Firefox "Multi-Account Containers" data
docs: |-
@@ -866,9 +860,9 @@ actions:
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call:
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: containers.json
pathGlob: containers.json
-
name: Clear Firefox open tabs and windows data
docs: |-
@@ -878,9 +872,9 @@ actions:
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call:
function: DeleteFromFirefoxProfiles
function: DeleteFilesFromFirefoxProfiles
parameters:
path: sessionstore.jsonlz4
pathGlob: sessionstore.jsonlz4
-
category: Clear system and kernel usage data
docs: |-
@@ -2911,7 +2905,8 @@ actions:
function: AddFirefoxPrefs
parameters:
prefName: toolkit.telemetry.log.level
jsonValue: 'Fatal'
jsonValue: >-
"Fatal"
-
name: Disable Firefox telemetry log output
recommend: standard
@@ -2924,7 +2919,8 @@ actions:
function: AddFirefoxPrefs
parameters:
prefName: toolkit.telemetry.log.dump
jsonValue: 'Fatal'
jsonValue: >-
"Fatal"
-
name: Clear Firefox telemetry user ID
recommend: standard
@@ -3491,16 +3487,66 @@ functions:
>&2 echo "Failed, $service does not exist."
fi
-
name: DeleteFromFirefoxProfiles
name: Comment
# 💡 Purpose:
# Adds a comment in the executed code for better readability and debugging.
# This function does not affect the execution flow but helps in understanding the purpose of subsequent code.
parameters:
- name: path # file or folder in profile file
code: |-
# {{ $path }}: Global installation
rm -rfv ~/.mozilla/firefox/*/{{ $path }}
# {{ $path }}: Flatpak installation
rm -rfv ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $path }}
# {{ $path }}: Snap installation
rm -rfv ~/snap/firefox/common/.mozilla/firefox/*/{{ $path }}
- name: codeComment
optional: true
- name: revertCodeComment
optional: true
call:
function: RunInlineCode
parameters:
code: '{{ with $codeComment }}# {{ . }}{{ end }}'
revertCode: '{{ with $revertCodeComment }}# {{ . }}{{ end }}'
-
name: DeleteFiles
parameters:
- name: fileGlob
call:
-
function: Comment
parameters:
codeComment: >-
Delete files matching pattern: "{{ $fileGlob }}"
-
function: RunPython3Code
parameters:
code: |-
import glob
import os
path = '{{ $fileGlob }}'
expanded_path = os.path.expandvars(os.path.expanduser(path))
print(f'Deleting files matching pattern: {expanded_path}')
paths = glob.glob(expanded_path)
if not paths:
print('Skipping, no paths found.')
for path in paths:
if not os.path.isfile(path):
print(f'Skipping folder: "{path}".')
continue
os.remove(path)
print(f'Successfully delete file: "{path}".')
print(f'Successfully deleted {len(paths)} file(s).')
-
name: DeleteFilesFromFirefoxProfiles
parameters:
- name: pathGlob # file or folder in profile file
call:
- # Global installation
function: DeleteFiles
parameters:
fileGlob: ~/.mozilla/firefox/*/{{ $pathGlob }}
- # Flatpak installation
function: DeleteFiles
parameters:
fileGlob: ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $pathGlob }}
- # Snap installation
function: DeleteFiles
parameters:
fileGlob: ~/snap/firefox/common/.mozilla/firefox/*/{{ $pathGlob }}
-
name: CleanTableFromFirefoxProfileDatabase
parameters:

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
export class Clipboard {
public static copyText(text: string): void {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', ''); // to avoid focus
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
}

View File

@@ -35,8 +35,7 @@
}
.#{$name}-leave-active,
.#{$name}-enter, // Vue 2.X compatibility
.#{$name}-enter-from // Vue 3.X compatibility
.#{$name}-enter-from
{
opacity: 0;

View File

@@ -1,20 +1,23 @@
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
import { VueBootstrapper } from './Modules/VueBootstrapper';
import { Bootstrapper } from './Bootstrapper';
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
import { AppInitializationLogger } from './Modules/AppInitializationLogger';
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
import type { App } from 'vue';
export class ApplicationBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
const bootstrappers = ApplicationBootstrapper.getAllBootstrappers();
for (const bootstrapper of bootstrappers) {
bootstrapper.bootstrap(vue);
export class ApplicationBootstrapper implements Bootstrapper {
constructor(private readonly bootstrappers = ApplicationBootstrapper.getAllBootstrappers()) { }
public async bootstrap(app: App): Promise<void> {
for (const bootstrapper of this.bootstrappers) {
// eslint-disable-next-line no-await-in-loop
await bootstrapper.bootstrap(app); // Not running `Promise.all` because order matters.
}
}
private static getAllBootstrappers(): IVueBootstrapper[] {
private static getAllBootstrappers(): Bootstrapper[] {
return [
new VueBootstrapper(),
new RuntimeSanityValidator(),
new DependencyBootstrapper(),
new AppInitializationLogger(),
];
}

View File

@@ -0,0 +1,5 @@
import type { App } from 'vue';
export interface Bootstrapper {
bootstrap(app: App): Promise<void>;
}

View File

@@ -5,6 +5,8 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { useClipboard } from '../components/Shared/Hooks/Clipboard/UseClipboard';
import { useCurrentCode } from '../components/Shared/Hooks/UseCurrentCode';
export function provideDependencies(
context: IApplicationContext,
@@ -23,6 +25,12 @@ export function provideDependencies(
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
return useCollectionState(context, events);
});
registerTransient(InjectionKeys.useClipboard, () => useClipboard());
registerTransient(InjectionKeys.useCurrentCode, () => {
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
const state = api.inject(InjectionKeys.useCollectionState)();
return useCurrentCode(state, events);
});
}
export interface VueDependencyInjectionApi {

View File

@@ -1,7 +0,0 @@
import { VueConstructor } from 'vue';
export interface IVueBootstrapper {
bootstrap(vue: VueConstructor): void;
}
export { VueConstructor };

View File

@@ -1,13 +1,13 @@
import { ILogger } from '@/infrastructure/Log/ILogger';
import { IVueBootstrapper } from '../IVueBootstrapper';
import { Bootstrapper } from '../Bootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements IVueBootstrapper {
export class AppInitializationLogger implements Bootstrapper {
constructor(
private readonly logger: ILogger = ClientLoggerFactory.Current.logger,
) { }
public bootstrap(): void {
public async bootstrap(): Promise<void> {
// Do not remove [APP_INIT]; it's a marker used in tests.
this.logger.info('[APP_INIT] Application is initialized.');
}

View File

@@ -0,0 +1,20 @@
import { inject, type App } from 'vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
import { Bootstrapper } from '../Bootstrapper';
export class DependencyBootstrapper implements Bootstrapper {
constructor(
private readonly contextFactory = buildContext,
private readonly dependencyProvider = provideDependencies,
private readonly injector = inject,
) { }
public async bootstrap(app: App): Promise<void> {
const context = await this.contextFactory();
this.dependencyProvider(context, {
provide: app.provide,
inject: this.injector,
});
}
}

View File

@@ -1,12 +1,12 @@
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { IVueBootstrapper } from '../IVueBootstrapper';
import { Bootstrapper } from '../Bootstrapper';
export class RuntimeSanityValidator implements IVueBootstrapper {
export class RuntimeSanityValidator implements Bootstrapper {
constructor(private readonly validator = validateRuntimeSanity) {
}
public bootstrap(): void {
public async bootstrap(): Promise<void> {
this.validator({
validateEnvironmentVariables: true,
validateWindowVariables: true,

View File

@@ -1,8 +0,0 @@
import { VueConstructor, IVueBootstrapper } from '../IVueBootstrapper';
export class VueBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
const { config } = vue;
config.productionTip = false;
}
}

View File

@@ -18,10 +18,6 @@ import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { provideDependencies } from '../bootstrapping/DependencyProvider';
const singletonAppContext = await buildContext();
const OptionalDevToolkit = process.env.NODE_ENV !== 'production'
? defineAsyncComponent(() => import('@/presentation/components/DevToolkit/DevToolkit.vue'))
@@ -36,9 +32,7 @@ export default defineComponent({
TheFooter,
OptionalDevToolkit,
},
setup() {
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
},
setup() { },
});
</script>

View File

@@ -0,0 +1,33 @@
<template>
<IconButton
text="Copy"
@click="copyCode"
icon-name="copy"
/>
</template>
<script lang="ts">
import {
defineComponent, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import IconButton from './IconButton.vue';
export default defineComponent({
components: {
IconButton,
},
setup() {
const { copyText } = inject(InjectionKeys.useClipboard)();
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
async function copyCode() {
await copyText(currentCode.value);
}
return {
copyCode,
};
},
});
</script>

View File

@@ -0,0 +1,59 @@
<template>
<IconButton
v-if="canRun"
text="Run"
@click="executeCode"
icon-name="play"
/>
</template>
<script lang="ts">
import {
defineComponent, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import IconButton from './IconButton.vue';
export default defineComponent({
components: {
IconButton,
},
setup() {
const { currentState, currentContext } = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
async function executeCode() {
await runCode(currentContext);
}
return {
isDesktopVersion: isDesktop,
canRun,
executeCode,
};
},
});
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
hostOs: OperatingSystem,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,
/* appName: */ context.app.info.name,
/* fileExtension: */ context.state.collection.scripting.fileExtension,
);
}
</script>

View File

@@ -1,15 +1,17 @@
<template>
<button
class="button"
type="button"
@click="onClicked"
>
<AppIcon
class="button__icon"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
<div class="button-wrapper">
<button
class="button"
type="button"
@click="onClicked"
>
<AppIcon
class="button__icon"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
</div>
</template>
<script lang="ts">
@@ -49,10 +51,20 @@ export default defineComponent({
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.button-wrapper {
position: relative;
height: 70px;
.button {
position: absolute;
width: 100%;
height: 100%;
}
}
.button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: $color-secondary;
color: $color-on-secondary;
@@ -70,19 +82,17 @@ export default defineComponent({
@include clickable;
width: 10%;
min-width: 90px;
@include hover-or-touch {
background: $color-surface;
box-shadow: 0px 2px 10px 5px $color-secondary;
.button__text {
display: block;
}
.button__icon {
display: none;
}
}
@include hover-or-touch('>&__text') {
display: block;
}
@include hover-or-touch('>&__icon') {
display: none;
}
&__text {
.button__text {
display: none;
font-family: $font-artistic;
font-size: 1.5em;

View File

@@ -0,0 +1,95 @@
<template>
<div>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
@click="saveCode"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="instructions" />
</ModalDialog>
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import IconButton from '../IconButton.vue';
import InstructionList from './Instructions/InstructionList.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
export default defineComponent({
components: {
IconButton,
InstructionList,
ModalDialog,
},
setup() {
const { currentState } = inject(InjectionKeys.useCollectionState)();
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const areInstructionsVisible = ref(false);
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
currentState.value.collection.os,
fileName.value,
));
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
areInstructionsVisible.value = true;
}
return {
isDesktopVersion: isDesktop,
instructions,
fileName,
areInstructionsVisible,
saveCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<span class="code-wrapper">
<span class="dollar">$</span>
<code><slot /></code>
<code ref="codeElement"><slot /></code>
<TooltipWrapper>
<AppIcon
class="copy-button"
@@ -16,10 +16,10 @@
</template>
<script lang="ts">
import { defineComponent, useSlots } from 'vue';
import { Clipboard } from '@/infrastructure/Clipboard';
import { defineComponent, shallowRef, inject } from 'vue';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
export default defineComponent({
components: {
@@ -27,15 +27,25 @@ export default defineComponent({
AppIcon,
},
setup() {
const slots = useSlots();
const { copyText } = inject(InjectionKeys.useClipboard)();
function copyCode() {
const code = slots.default()[0].text;
Clipboard.copyText(code);
const codeElement = shallowRef<HTMLElement | undefined>();
async function copyCode() {
const element = codeElement.value;
if (!element) {
throw new Error('Code element could not be found.');
}
const code = element.textContent;
if (!code) {
throw new Error('Code element does not contain any text.');
}
await copyText(code);
}
return {
copyCode,
codeElement,
};
},
});

View File

@@ -1,168 +1,36 @@
<template>
<div class="container" v-if="hasCode">
<IconButton
v-if="canRun"
text="Run"
v-on:click="executeCode"
icon-name="play"
/>
<IconButton
:text="isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCode"
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
/>
<IconButton
text="Copy"
v-on:click="copyCode"
icon-name="copy"
/>
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
<InstructionList :data="instructions" />
</ModalDialog>
<CodeRunButton class="code-button" />
<CodeSaveButton class="code-button" />
<CodeCopyButton class="code-button" />
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, computed, inject,
defineComponent, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import InstructionList from './Instructions/InstructionList.vue';
import IconButton from './IconButton.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
import CodeRunButton from './CodeRunButton.vue';
import CodeCopyButton from './CodeCopyButton.vue';
import CodeSaveButton from './Save/CodeSaveButton.vue';
export default defineComponent({
components: {
IconButton,
InstructionList,
ModalDialog,
CodeRunButton,
CodeCopyButton,
CodeSaveButton,
},
setup() {
const {
currentState, currentContext, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
const areInstructionsVisible = ref(false);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const hasCode = ref(false);
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
currentState.value.collection.os,
fileName.value,
));
async function copyCode() {
const code = await getCurrentCode();
Clipboard.copyText(code.current);
}
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
areInstructionsVisible.value = true;
}
async function executeCode() {
await runCode(currentContext);
}
onStateChange((newState) => {
updateCurrentCode(newState.code.current);
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
events.unsubscribeAllAndRegister([
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
]);
}
function updateCurrentCode(code: string) {
hasCode.value = code && code.length > 0;
}
async function getCurrentCode(): Promise<IApplicationCode> {
const { code } = currentContext.state;
return code;
}
const hasCode = computed<boolean>(() => currentCode.value.length > 0);
return {
isDesktopVersion: isDesktop,
canRun,
hasCode,
instructions,
fileName,
areInstructionsVisible,
copyCode,
saveCode,
executeCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
hostOs: OperatingSystem,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
}
function getType(language: ScriptingLanguage) {
switch (language) {
case ScriptingLanguage.batchfile:
return FileType.BatchFile;
case ScriptingLanguage.shellscript:
return FileType.ShellScript;
default:
throw new Error('unknown file type');
}
}
function buildFileName(scripting: IScriptingDefinition) {
const fileName = 'privacy-script';
if (scripting.fileExtension) {
return `${fileName}.${scripting.fileExtension}`;
}
return fileName;
}
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,
/* appName: */ context.app.info.name,
/* fileExtension: */ context.state.collection.scripting.fileExtension,
);
}
</script>
<style scoped lang="scss">
@@ -170,8 +38,10 @@ async function runCode(context: IReadOnlyApplicationContext) {
display: flex;
flex-direction: row;
justify-content: center;
gap: 30px;
}
.container > * + * {
margin-left: 30px;
.code-button {
width: 10%;
min-width: 90px;
}
</style>

View File

@@ -185,14 +185,16 @@ function getDefaultCode(language: ScriptingLanguage): string {
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
::v-deep .code-area {
min-height: 200px;
width: 100%;
height: 100%;
overflow: auto;
&__highlight {
background-color: $color-secondary-light;
position: absolute;
:deep() {
.code-area {
min-height: 200px;
width: 100%;
height: 100%;
overflow: auto;
&__highlight {
background-color: $color-secondary-light;
position: absolute;
}
}
}
</style>

View File

@@ -28,8 +28,8 @@ $gap: 0.25rem;
font-family: $font-normal;
display: flex;
align-items: center;
.items {
* + *::before {
:deep(.items) {
> * + *::before {
content: '|';
padding-right: $gap;
padding-left: $gap;

View File

@@ -1,5 +1,9 @@
<template>
<span> <!-- Parent wrapper allows adding content inside with CSS without making it clickable -->
<span>
<!--
Parent wrapper allows `MenuOptionList` to safely add content inside
such as adding content in `::before` block without making it clickable.
-->
<span
v-bind:class="{
disabled: !enabled,

View File

@@ -19,7 +19,7 @@
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { defineComponent, shallowRef } from 'vue';
import SliderHandle from './SliderHandle.vue';
export default defineComponent({
@@ -45,7 +45,7 @@ export default defineComponent({
},
},
setup() {
const firstElement = ref<HTMLElement>();
const firstElement = shallowRef<HTMLElement>();
function onResize(displacementX: number): void {
const leftWidth = firstElement.value.offsetWidth + displacementX;

View File

@@ -50,7 +50,7 @@
<script lang="ts">
import {
defineComponent, ref, watch, computed,
inject,
inject, shallowRef,
} from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
@@ -95,7 +95,7 @@ export default defineComponent({
const isAnyChildSelected = ref(false);
const areAllChildrenSelected = ref(false);
const cardElement = ref<HTMLElement>();
const cardElement = shallowRef<HTMLElement>();
const cardTitle = computed<string | undefined>(() => {
if (!props.categoryId || !currentState.value) {

View File

@@ -11,7 +11,7 @@ export function hasDirective(el: Element): boolean {
}
export const NonCollapsing: ObjectDirective<HTMLElement> = {
inserted(el: HTMLElement) { // In Vue 3, use "mounted"
mounted(el: HTMLElement) {
el.setAttribute(attributeName, '');
},
};

View File

@@ -20,7 +20,7 @@ import { defineComponent, computed } from 'vue';
export default defineComponent({
props: {
value: Boolean,
modelValue: Boolean,
label: {
type: String,
required: true,
@@ -32,19 +32,19 @@ export default defineComponent({
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
input: (isChecked: boolean) => true,
'update:modelValue': (isChecked: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(props, { emit }) {
const isChecked = computed({
get() {
return props.value;
return props.modelValue;
},
set(value: boolean) {
if (value === props.value) {
if (value === props.modelValue) {
return;
}
emit('input', value);
emit('update:modelValue', value);
},
});

View File

@@ -2,14 +2,6 @@ import type { ReadOnlyTreeNode } from '../Node/TreeNode';
export interface TreeViewFilterEvent {
readonly action: TreeViewFilterAction;
/**
* A simple numeric value to ensure uniqueness of each event.
*
* This property is used to guarantee that the watch function will trigger
* even if the same filter action value is emitted consecutively.
*/
readonly timestamp: Date;
readonly predicate?: TreeViewFilterPredicate;
}
@@ -25,7 +17,6 @@ export function createFilterTriggeredEvent(
): TreeViewFilterEvent {
return {
action: TreeViewFilterAction.Triggered,
timestamp: new Date(),
predicate,
};
}
@@ -33,6 +24,5 @@ export function createFilterTriggeredEvent(
export function createFilterRemovedEvent(): TreeViewFilterEvent {
return {
action: TreeViewFilterAction.Removed,
timestamp: new Date(),
};
}

View File

@@ -184,10 +184,7 @@ export default defineComponent({
transform: translateX(0);
}
.#{$name}-enter,
// Vue 2.X compatibility
.#{$name}-enter-from,
// Vue 3.X compatibility
.#{$name}-leave-to {
opacity: 0;
transform: translateX(-2em);

View File

@@ -1,5 +1,5 @@
import {
WatchSource, inject, ref, watch,
WatchSource, inject, shallowRef, watch,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { ReadOnlyTreeNode } from './TreeNode';
@@ -10,7 +10,7 @@ export function useNodeState(
) {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const state = ref<TreeNodeStateDescriptor>();
const state = shallowRef<TreeNodeStateDescriptor>();
watch(nodeWatcher, (node: ReadOnlyTreeNode) => {
if (!node) {

View File

@@ -1,29 +0,0 @@
import { ReadOnlyTreeNode } from '../../Node/TreeNode';
import { RenderQueueOrderer } from './RenderQueueOrderer';
export class CollapseDepthOrderer implements RenderQueueOrderer {
public orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[] {
return orderNodes(nodes);
}
}
function orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[] {
return [...nodes]
.sort((a, b) => {
const [aCollapseStatus, bCollapseStatus] = [isNodeCollapsed(a), isNodeCollapsed(b)];
if (aCollapseStatus !== bCollapseStatus) {
return (aCollapseStatus ? 1 : 0) - (bCollapseStatus ? 1 : 0);
}
return a.hierarchy.depthInTree - b.hierarchy.depthInTree;
});
}
function isNodeCollapsed(node: ReadOnlyTreeNode): boolean {
if (!node.state.current.isExpanded) {
return true;
}
if (node.hierarchy.parent) {
return isNodeCollapsed(node.hierarchy.parent);
}
return false;
}

View File

@@ -0,0 +1,35 @@
import { ReadOnlyTreeNode } from '../../Node/TreeNode';
import { RenderQueueOrderer } from './RenderQueueOrderer';
export class CollapsedParentOrderer implements RenderQueueOrderer {
public orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[] {
return orderNodes(nodes);
}
}
function orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[] {
return [...nodes]
.map((node, index) => ({ node, index }))
.sort((a, b) => {
const [
isANodeOfCollapsedParent,
isBNodeOfCollapsedParent,
] = [isParentCollapsed(a.node), isParentCollapsed(b.node)];
if (isANodeOfCollapsedParent !== isBNodeOfCollapsedParent) {
return (isANodeOfCollapsedParent ? 1 : 0) - (isBNodeOfCollapsedParent ? 1 : 0);
}
return a.index - b.index;
})
.map(({ node }) => node);
}
function isParentCollapsed(node: ReadOnlyTreeNode): boolean {
const parentNode = node.hierarchy.parent;
if (parentNode) {
if (!parentNode.state.current.isExpanded) {
return true;
}
return isParentCollapsed(parentNode);
}
return false;
}

View File

@@ -9,7 +9,7 @@ import { NodeRenderingStrategy } from './Scheduling/NodeRenderingStrategy';
import { DelayScheduler } from './DelayScheduler';
import { TimeoutDelayScheduler } from './Scheduling/TimeoutDelayScheduler';
import { RenderQueueOrderer } from './Ordering/RenderQueueOrderer';
import { CollapseDepthOrderer } from './Ordering/CollapseDepthOrderer';
import { CollapsedParentOrderer } from './Ordering/CollapsedParentOrderer';
/**
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
@@ -21,7 +21,7 @@ export function useGradualNodeRendering(
scheduler: DelayScheduler = new TimeoutDelayScheduler(),
initialBatchSize = 30,
subsequentBatchSize = 5,
orderer: RenderQueueOrderer = new CollapseDepthOrderer(),
orderer: RenderQueueOrderer = new CollapsedParentOrderer(),
): NodeRenderingStrategy {
const nodesToRender = new Set<ReadOnlyTreeNode>();
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());

View File

@@ -14,7 +14,7 @@
<script lang="ts">
import {
defineComponent, onMounted, watch,
ref, PropType,
shallowRef, PropType,
} from 'vue';
import { TreeRootManager } from './TreeRoot/TreeRootManager';
import TreeRoot from './TreeRoot/TreeRoot.vue';
@@ -53,7 +53,7 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const treeContainerElement = ref<HTMLElement | undefined>();
const treeContainerElement = shallowRef<HTMLElement | undefined>();
const tree = new TreeRootManager();

View File

@@ -1,5 +1,5 @@
import {
WatchSource, watch, inject, readonly, ref,
WatchSource, watch, inject, shallowReadonly, shallowRef,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { TreeRoot } from './TreeRoot/TreeRoot';
@@ -8,8 +8,8 @@ import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const tree = ref<TreeRoot | undefined>();
const nodes = ref<QueryableNodes | undefined>();
const tree = shallowRef<TreeRoot | undefined>();
const nodes = shallowRef<QueryableNodes | undefined>();
watch(treeWatcher, (newTree) => {
tree.value = newTree;
@@ -22,6 +22,6 @@ export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
}, { immediate: true });
return {
nodes: readonly(nodes),
nodes: shallowReadonly(nodes),
};
}

View File

@@ -1,5 +1,5 @@
import {
WatchSource, inject, watch, ref,
WatchSource, inject, watch, shallowRef,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
@@ -17,7 +17,7 @@ export function useNodeStateChangeAggregator(
const { nodes } = useTreeNodes(treeWatcher);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const onNodeChangeCallback = ref<NodeStateChangeEventCallback>();
const onNodeChangeCallback = shallowRef<NodeStateChangeEventCallback>();
watch([
() => nodes.value,

View File

@@ -1,5 +1,5 @@
import {
computed, inject, readonly, ref,
computed, inject, shallowReadonly, shallowRef, triggerRef,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
@@ -15,7 +15,7 @@ export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
});
return {
selectedScriptNodeIds: readonly(selectedNodeIds),
selectedScriptNodeIds: shallowReadonly(selectedNodeIds),
};
}
@@ -23,18 +23,28 @@ function useSelectedScripts() {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
const selectedScripts = ref<readonly SelectedScript[]>([]);
const selectedScripts = shallowRef<readonly SelectedScript[]>([]);
function updateSelectedScripts(newReference: readonly SelectedScript[]) {
if (selectedScripts.value === newReference) {
// Manually trigger update if the array was mutated using the same reference.
// Array might have been mutated without changing the reference
triggerRef(selectedScripts);
} else {
selectedScripts.value = newReference;
}
}
onStateChange((state) => {
selectedScripts.value = state.selection.selectedScripts;
updateSelectedScripts(state.selection.selectedScripts);
events.unsubscribeAllAndRegister([
state.selection.changed.on((scripts) => {
selectedScripts.value = scripts;
updateSelectedScripts(scripts);
}),
]);
}, { immediate: true });
return {
selectedScripts: readonly(selectedScripts),
selectedScripts: shallowReadonly(selectedScripts),
};
}

View File

@@ -1,5 +1,5 @@
import {
Ref, inject, readonly, ref,
Ref, inject, shallowReadonly, shallowRef,
} from 'vue';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
@@ -21,7 +21,7 @@ export function useTreeViewFilterEvent() {
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const latestFilterEvent = ref<TreeViewFilterEvent | undefined>(undefined);
const latestFilterEvent = shallowRef<TreeViewFilterEvent | undefined>(undefined);
const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches(
getNodeMetadata(node),
@@ -36,7 +36,7 @@ export function useTreeViewFilterEvent() {
}, { immediate: true });
return {
latestFilterEvent: readonly(latestFilterEvent),
latestFilterEvent: shallowReadonly(latestFilterEvent),
};
}

View File

@@ -0,0 +1,15 @@
import { Clipboard } from './Clipboard';
export type NavigatorClipboard = typeof globalThis.navigator.clipboard;
export class BrowserClipboard implements Clipboard {
constructor(
private readonly navigatorClipboard: NavigatorClipboard = globalThis.navigator.clipboard,
) {
}
public async copyText(text: string): Promise<void> {
await this.navigatorClipboard.writeText(text);
}
}

View File

@@ -0,0 +1,3 @@
export interface Clipboard {
copyText(text: string): Promise<void>;
}

View File

@@ -0,0 +1,13 @@
import { FunctionKeys } from '@/TypeHelpers';
import { BrowserClipboard } from './BrowserClipboard';
import { Clipboard } from './Clipboard';
export function useClipboard(clipboard: Clipboard = new BrowserClipboard()) {
// Bind functions for direct use from destructured assignments such as `const { .. } = ...`.
const functionKeys: readonly FunctionKeys<Clipboard>[] = ['copyText'];
functionKeys.forEach((functionName) => {
const fn = clipboard[functionName];
clipboard[functionName] = fn.bind(clipboard);
});
return clipboard;
}

View File

@@ -1,6 +1,4 @@
import {
ref, computed, readonly,
} from 'vue';
import { shallowRef, shallowReadonly } from 'vue';
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
@@ -16,7 +14,7 @@ export function useCollectionState(
throw new Error('missing events');
}
const currentState = ref<ICategoryCollectionState>(context.state);
const currentState = shallowRef<IReadOnlyCategoryCollectionState>(context.state);
events.register([
context.contextChanged.on((event) => {
currentState.value = event.newState;
@@ -66,8 +64,7 @@ export function useCollectionState(
modifyCurrentContext,
onStateChange,
currentContext: context as IReadOnlyApplicationContext,
currentState: readonly(computed<IReadOnlyCategoryCollectionState>(() => currentState.value)),
events: events as IEventSubscriptionCollection,
currentState: shallowReadonly(currentState),
};
}

View File

@@ -0,0 +1,32 @@
import { ref } from 'vue';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
import { useCollectionState } from './UseCollectionState';
export function useCurrentCode(
state: ReturnType<typeof useCollectionState>,
events: IEventSubscriptionCollection,
) {
const { onStateChange } = state;
const currentCode = ref<string>('');
onStateChange((newState) => {
updateCurrentCode(newState.code.current);
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
events.unsubscribeAllAndRegister([
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
]);
}
function updateCurrentCode(newCode: string) {
currentCode.value = newCode;
}
return {
currentCode,
};
}

View File

@@ -0,0 +1,27 @@
import { onMounted } from 'vue';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
// AsyncLazy ensures single load of the ResizeObserver polyfill,
// even when multiple calls are made simultaneously.
const polyfillLoader = new AsyncLazy(async () => {
if ('ResizeObserver' in window) {
return window.ResizeObserver;
}
const module = await import('@juggle/resize-observer');
globalThis.window.ResizeObserver = module.ResizeObserver;
return module.ResizeObserver;
});
async function polyfillResizeObserver(): Promise<typeof ResizeObserver> {
return polyfillLoader.getValue();
}
export function useResizeObserverPolyfill() {
const resizeObserverReady = new Promise<void>((resolve) => {
onMounted(async () => {
await polyfillResizeObserver();
resolve();
});
});
return { resizeObserverReady };
}

View File

@@ -1,5 +1,9 @@
<template>
<div v-html="svgContent" class="inline-icon" />
<div
class="inline-icon"
v-html="svgContent"
@click="onClicked"
/>
</template>
<script lang="ts">
@@ -18,10 +22,19 @@ export default defineComponent({
required: true,
},
},
setup(props) {
emits: [
'click',
],
setup(props, { emit }) {
const useSvgLoaderHook = inject('useSvgLoaderHook', useSvgLoader);
const { svgContent } = useSvgLoaderHook(() => props.icon);
return { svgContent };
function onClicked() {
emit('click');
}
return { svgContent, onClicked };
},
});
@@ -30,7 +43,7 @@ export default defineComponent({
<style lang="scss" scoped>
.inline-icon {
display: inline-block;
::v-deep svg { // using ::v-deep because when v-html is used the content doesn't go through Vue's template compiler.
:deep(svg) { // using :deep because when v-html is used the content doesn't go through Vue's template compiler.
display: inline-block;
height: 1em;
overflow: visible;

View File

@@ -1,5 +1,5 @@
import {
WatchSource, readonly, ref, watch,
WatchSource, shallowReadonly, ref, watch,
} from 'vue';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IconName } from './IconName';
@@ -15,7 +15,7 @@ export function useSvgLoader(
}, { immediate: true });
return {
svgContent: readonly(svgContent),
svgContent: shallowReadonly(svgContent),
};
}
@@ -62,7 +62,7 @@ const RawSvgLoaders = import.meta.glob('@/presentation/assets/icons/**/*.svg', {
});
function modifySvg(svgSource: string): string {
const parser = new DOMParser();
const parser = new window.DOMParser();
const doc = parser.parseFromString(svgSource, 'image/svg+xml');
let svgRoot = doc.documentElement;
svgRoot = removeSvgComments(svgRoot);

View File

@@ -36,11 +36,11 @@ export default defineComponent({
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
input: (isOpen: boolean) => true,
'update:modelValue': (isOpen: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
props: {
value: {
modelValue: {
type: Boolean,
required: true,
},
@@ -67,13 +67,13 @@ export default defineComponent({
onModalFullyTransitionedOut(() => {
isRendered.value = false;
resetTransitionStatus();
if (props.value) {
emit('input', false);
if (props.modelValue) {
emit('update:modelValue', false);
}
});
watchEffect(() => {
if (props.value) {
if (props.modelValue) {
open();
} else {
close();
@@ -99,8 +99,8 @@ export default defineComponent({
isOpen.value = false;
if (props.value) {
emit('input', false);
if (props.modelValue) {
emit('update:modelValue', false);
}
}
@@ -115,8 +115,8 @@ export default defineComponent({
isOpen.value = true;
});
if (!props.value) {
emit('input', true);
if (!props.modelValue) {
emit('update:modelValue', true);
}
}

View File

@@ -18,7 +18,7 @@
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { defineComponent, shallowRef } from 'vue';
export default defineComponent({
props: {
@@ -31,7 +31,7 @@ export default defineComponent({
'transitionedOut',
],
setup(_, { emit }) {
const modalElement = ref<HTMLElement>();
const modalElement = shallowRef<HTMLElement>();
function onAfterTransitionLeave() {
emit('transitionedOut');

View File

@@ -28,21 +28,21 @@ export default defineComponent({
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
input: (isOpen: boolean) => true,
'update:modelValue': (isOpen: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
props: {
value: {
modelValue: {
type: Boolean,
required: true,
},
},
setup(props, { emit }) {
const showDialog = computed({
get: () => props.value,
get: () => props.modelValue,
set: (value) => {
if (value !== props.value) {
emit('input', value);
if (value !== props.modelValue) {
emit('update:modelValue', value);
}
},
});

View File

@@ -1,13 +1,14 @@
<template>
<div ref="containerElement" class="container">
<slot ref="containerElement" />
<slot />
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, onMounted, onBeforeUnmount,
defineComponent, shallowRef, onMounted, onBeforeUnmount,
} from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
export default defineComponent({
emits: {
@@ -18,18 +19,22 @@ export default defineComponent({
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(_, { emit }) {
const containerElement = ref<HTMLElement>();
const { resizeObserverReady } = useResizeObserverPolyfill();
const containerElement = shallowRef<HTMLElement>();
let width = 0;
let height = 0;
let observer: ResizeObserver;
onMounted(async () => {
onMounted(() => {
width = containerElement.value.offsetWidth;
height = containerElement.value.offsetHeight;
observer = await initializeResizeObserver(updateSize);
observer.observe(containerElement.value);
resizeObserverReady.then(() => {
observer = new ResizeObserver(updateSize);
observer.observe(containerElement.value);
});
fireChangeEvents();
});
@@ -38,16 +43,6 @@ export default defineComponent({
observer?.disconnect();
});
async function initializeResizeObserver(
callback: ResizeObserverCallback,
): Promise<ResizeObserver> {
if ('ResizeObserver' in window) {
return new window.ResizeObserver(callback);
}
const module = await import('@juggle/resize-observer');
return new module.ResizeObserver(callback);
}
function updateSize() {
let sizeChanged = false;
if (isWidthChanged()) {

View File

@@ -24,10 +24,11 @@
<script lang="ts">
import {
useFloating, arrow, shift, flip, Placement, offset, Side, Coords,
useFloating, arrow, shift, flip, Placement, offset, Side, Coords, autoUpdate,
} from '@floating-ui/vue';
import { defineComponent, ref, computed } from 'vue';
import type { CSSProperties } from 'vue/types/jsx'; // In Vue 3.0 import from 'vue'
import { defineComponent, shallowRef, computed } from 'vue';
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
import type { CSSProperties } from 'vue';
const GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX = 2;
const ARROW_SIZE_IN_PX = 4;
@@ -35,16 +36,18 @@ const MARGIN_FROM_DOCUMENT_EDGE_IN_PX = 2;
export default defineComponent({
setup() {
const tooltipDisplayElement = ref<HTMLElement | undefined>();
const triggeringElement = ref<HTMLElement | undefined>();
const arrowElement = ref<HTMLElement | undefined>();
const placement = ref<Placement>('top');
const tooltipDisplayElement = shallowRef<HTMLElement | undefined>();
const triggeringElement = shallowRef<HTMLElement | undefined>();
const arrowElement = shallowRef<HTMLElement | undefined>();
const placement = shallowRef<Placement>('top');
useResizeObserverPolyfill();
const { floatingStyles, middlewareData } = useFloating(
triggeringElement,
tooltipDisplayElement,
{
placement: ref(placement),
placement,
middleware: [
offset(ARROW_SIZE_IN_PX + GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX),
/* Shifts the element along the specified axes in order to keep it in view. */
@@ -56,6 +59,7 @@ export default defineComponent({
flip(),
arrow({ element: arrowElement }),
],
whileElementsMounted: autoUpdate,
},
);
const arrowStyles = computed<CSSProperties>(() => {
@@ -101,9 +105,8 @@ function getArrowPositionStyles(
} else if (y) { // either X or Y is calculated
style.top = `${y}px`;
}
const oppositeSide = getCounterpartBoxOffsetProperty(placement) as never;
// Cast to `never` due to ts(2590) from JSX import. Remove after migrating to Vue 3.0.
style[oppositeSide] = `-${ARROW_SIZE_IN_PX}px`;
const oppositeSide = getCounterpartBoxOffsetProperty(placement);
style[oppositeSide.toString()] = `-${ARROW_SIZE_IN_PX}px`;
return style;
}

View File

@@ -1,7 +1,9 @@
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
import type { useAutoUnsubscribedEvents } from './components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import type { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import type { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { InjectionKey } from 'vue';
export const InjectionKeys = {
@@ -9,6 +11,8 @@ export const InjectionKeys = {
useApplication: defineSingletonKey<ReturnType<typeof useApplication>>('useApplication'),
useRuntimeEnvironment: defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment'),
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
};
function defineSingletonKey<T>(key: string): InjectionKey<T> {

View File

@@ -1,10 +1,10 @@
import Vue from 'vue';
import { createApp } from 'vue';
import App from './components/App.vue';
import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper';
new ApplicationBootstrapper()
.bootstrap(Vue);
const app = createApp(App);
new Vue({
render: (h) => h(App),
}).$mount('#app');
await new ApplicationBootstrapper()
.bootstrap(app);
app.mount('#app');

View File

@@ -1,15 +0,0 @@
import Vue, { VNode } from 'vue';
declare global {
namespace JSX {
interface Element extends VNode {
}
interface ElementClass extends Vue {
}
interface IntrinsicElements {
[elem: string]: any;
}
}
}

View File

@@ -1,7 +0,0 @@
/* eslint-disable */
declare module '*.vue' {
import { DefineComponent } from 'vue';
const component: DefineComponent;
export default component;
}

View File

@@ -2,7 +2,7 @@ import {
describe, it, expect,
} from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { defineComponent, shallowRef } from 'vue';
import TreeView from '@/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue';
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
@@ -33,8 +33,8 @@ function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) {
setup() {
provideDependencies(new ApplicationContextStub());
const initialNodes = ref(initialNodeData);
const selectedLeafNodeIds = ref<readonly string[]>([]);
const initialNodes = shallowRef(initialNodeData);
const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
return {
initialNodes,
selectedLeafNodeIds,

View File

@@ -1,4 +1,4 @@
import { afterEach } from 'vitest';
import { enableAutoDestroy } from '@vue/test-utils';
import { enableAutoUnmount } from '@vue/test-utils';
enableAutoDestroy(afterEach);
enableAutoUnmount(afterEach);

View File

@@ -3,8 +3,8 @@ import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'
import { UserFilter } from '@/application/Context/State/Filter/UserFilter';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { FilterChangeDetailsStub } from '@tests/unit/shared/Stubs/FilterChangeDetailsStub';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
@@ -12,7 +12,7 @@ describe('UserFilter', () => {
describe('clearFilter', () => {
it('signals when removing filter', () => {
// arrange
const expectedChange = FilterChange.forClear();
const expectedChange = FilterChangeDetailsStub.forClear();
let actualChange: IFilterChangeDetails;
const sut = new UserFilter(new CategoryCollectionStub());
sut.filterChanged.on((change) => {

View File

@@ -1,126 +1,295 @@
import { randomUUID } from 'crypto';
import { describe, it, expect } from 'vitest';
import { ExpressionRegexBuilder } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder';
const AllWhitespaceCharacters = ' \t\n\r\v\f\u00A0';
describe('ExpressionRegexBuilder', () => {
describe('expectCharacters', () => {
describe('escape single as expected', () => {
const charactersToEscape = ['.', '$'];
for (const character of charactersToEscape) {
it(character, () => {
expectRegex(
// act
describe('expectCharacters', () => {
describe('escapes single character as expected', () => {
const charactersToEscape = ['.', '$'];
for (const character of charactersToEscape) {
it(`escapes ${character} as expected`, () => expectMatch(
character,
(act) => act.expectCharacters(character),
// assert
`\\${character}`,
);
});
}
});
it('escapes multiple as expected', () => {
expectRegex(
// act
`${character}`,
));
}
});
it('escapes multiple characters as expected', () => expectMatch(
'.I have no $$.',
(act) => act.expectCharacters('.I have no $$.'),
// assert
'\\.I have no \\$\\$\\.',
);
});
it('adds as expected', () => {
expectRegex(
// act
(act) => act.expectCharacters('return as it is'),
// assert
'.I have no $$.',
));
it('adds characters as expected', () => expectMatch(
'return as it is',
);
(act) => act.expectCharacters('return as it is'),
'return as it is',
));
});
});
it('expectOneOrMoreWhitespaces', () => {
expectRegex(
// act
describe('expectOneOrMoreWhitespaces', () => {
it('matches one whitespace', () => expectMatch(
' ',
(act) => act.expectOneOrMoreWhitespaces(),
// assert
'\\s+',
);
' ',
));
it('matches multiple whitespaces', () => expectMatch(
AllWhitespaceCharacters,
(act) => act.expectOneOrMoreWhitespaces(),
AllWhitespaceCharacters,
));
it('matches whitespaces inside text', () => expectMatch(
`start${AllWhitespaceCharacters}end`,
(act) => act.expectOneOrMoreWhitespaces(),
AllWhitespaceCharacters,
));
it('does not match non-whitespace characters', () => expectNonMatch(
'a',
(act) => act.expectOneOrMoreWhitespaces(),
));
});
it('matchPipeline', () => {
expectRegex(
// act
(act) => act.matchPipeline(),
// assert
'\\s*(\\|\\s*.+?)?',
);
describe('captureOptionalPipeline', () => {
it('does not capture when no pipe is present', () => expectNonMatch(
'noPipeHere',
(act) => act.captureOptionalPipeline(),
));
it('captures when input starts with pipe', () => expectCapture(
'| afterPipe',
(act) => act.captureOptionalPipeline(),
'| afterPipe',
));
it('ignores without text before', () => expectCapture(
'stuff before | afterPipe',
(act) => act.captureOptionalPipeline(),
'| afterPipe',
));
it('ignores without text before', () => expectCapture(
'stuff before | afterPipe',
(act) => act.captureOptionalPipeline(),
'| afterPipe',
));
it('ignores whitespaces before the pipe', () => expectCapture(
' | afterPipe',
(act) => act.captureOptionalPipeline(),
'| afterPipe',
));
it('ignores text after whitespace', () => expectCapture(
'| first Pipe',
(act) => act.captureOptionalPipeline(),
'| first ',
));
describe('non-greedy matching', () => { // so the rest of the pattern can work
it('non-letter character in pipe', () => expectCapture(
'| firstPipe | sec0ndpipe',
(act) => act.captureOptionalPipeline(),
'| firstPipe ',
));
});
});
it('matchUntilFirstWhitespace', () => {
expectRegex(
// act
(act) => act.matchUntilFirstWhitespace(),
// assert
'([^|\\s]+)',
);
it('matches until first whitespace', () => expectMatch(
describe('captureUntilWhitespaceOrPipe', () => {
it('captures until first whitespace', () => expectCapture(
// arrange
'first second',
'first ',
// act
(act) => act.matchUntilFirstWhitespace(),
(act) => act.captureUntilWhitespaceOrPipe(),
// assert
'first',
));
});
describe('matchMultilineAnythingExceptSurroundingWhitespaces', () => {
it('returns expected regex', () => expectRegex(
// act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'\\s*([\\S\\s]+?)\\s*',
));
it('matches single line', () => expectMatch(
it('captures until first pipe', () => expectCapture(
// arrange
'single line',
'first|',
// act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
(act) => act.captureUntilWhitespaceOrPipe(),
// assert
'single line',
'first',
));
it('matches single line without surrounding whitespaces', () => expectMatch(
it('captures all without whitespace or pipe', () => expectCapture(
// arrange
' single line\t',
'all',
// act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
(act) => act.captureUntilWhitespaceOrPipe(),
// assert
'single line',
));
it('matches multiple lines', () => expectMatch(
// arrange
'first line\nsecond line',
// act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'first line\nsecond line',
));
it('matches multiple lines without surrounding whitespaces', () => expectMatch(
// arrange
' first line\nsecond line\t',
// act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'first line\nsecond line',
'all',
));
});
it('expectExpressionStart', () => {
expectRegex(
// act
describe('captureMultilineAnythingExceptSurroundingWhitespaces', () => {
describe('single line', () => {
it('captures a line without surrounding whitespaces', () => expectCapture(
// arrange
'line',
// act
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'line',
));
it('captures a line with internal whitespaces intact', () => expectCapture(
`start${AllWhitespaceCharacters}end`,
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
`start${AllWhitespaceCharacters}end`,
));
it('excludes surrounding whitespaces', () => expectCapture(
// arrange
`${AllWhitespaceCharacters}single line\t`,
// act
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'single line',
));
});
describe('multiple lines', () => {
it('captures text across multiple lines', () => expectCapture(
// arrange
'first line\nsecond line\r\nthird-line',
// act
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'first line\nsecond line\r\nthird-line',
));
it('captures text with empty lines in between', () => expectCapture(
'start\n\nend',
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
'start\n\nend',
));
it('excludes surrounding whitespaces from multiline text', () => expectCapture(
// arrange
` first line\nsecond line${AllWhitespaceCharacters}`,
// act
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'first line\nsecond line',
));
});
describe('edge cases', () => {
it('does not capture for input with only whitespaces', () => expectNonCapture(
AllWhitespaceCharacters,
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
));
});
});
describe('expectExpressionStart', () => {
it('matches expression start without trailing whitespaces', () => expectMatch(
'{{expression',
(act) => act.expectExpressionStart(),
// assert
'{{\\s*',
);
'{{',
));
it('matches expression start with trailing whitespaces', () => expectMatch(
`{{${AllWhitespaceCharacters}expression`,
(act) => act.expectExpressionStart(),
`{{${AllWhitespaceCharacters}`,
));
it('does not match whitespaces not directly after expression start', () => expectMatch(
' {{expression',
(act) => act.expectExpressionStart(),
'{{',
));
it('does not match if expression start is not present', () => expectNonMatch(
'noExpressionStartHere',
(act) => act.expectExpressionStart(),
));
});
it('expectExpressionEnd', () => {
expectRegex(
// act
describe('expectExpressionEnd', () => {
it('matches expression end without preceding whitespaces', () => expectMatch(
'expression}}',
(act) => act.expectExpressionEnd(),
// assert
'\\s*}}',
);
'}}',
));
it('matches expression end with preceding whitespaces', () => expectMatch(
`expression${AllWhitespaceCharacters}}}`,
(act) => act.expectExpressionEnd(),
`${AllWhitespaceCharacters}}}`,
));
it('does not capture whitespaces not directly before expression end', () => expectMatch(
'expression}} ',
(act) => act.expectExpressionEnd(),
'}}',
));
it('does not match if expression end is not present', () => expectNonMatch(
'noExpressionEndHere',
(act) => act.expectExpressionEnd(),
));
});
describe('expectOptionalWhitespaces', () => {
describe('matching', () => {
it('matches multiple Unix lines', () => expectMatch(
// arrange
'\n\n',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
'\n\n',
));
it('matches multiple Windows lines', () => expectMatch(
// arrange
'\r\n',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
'\r\n',
));
it('matches multiple spaces', () => expectMatch(
// arrange
' ',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
' ',
));
it('matches horizontal and vertical tabs', () => expectMatch(
// arrange
'\t\v',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
'\t\v',
));
it('matches form feed character', () => expectMatch(
// arrange
'\f',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
'\f',
));
it('matches a non-breaking space character', () => expectMatch(
// arrange
'\u00A0',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
'\u00A0',
));
it('matches a combination of whitespace characters', () => expectMatch(
// arrange
AllWhitespaceCharacters,
// act
(act) => act.expectOptionalWhitespaces(),
// assert
AllWhitespaceCharacters,
));
it('matches whitespace characters on different positions', () => expectMatch(
// arrange
'\ta\nb\rc\v',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
'\t\n\r\v',
));
});
describe('non-matching', () => {
it('a non-whitespace character', () => expectNonMatch(
// arrange
'a',
// act
(act) => act.expectOptionalWhitespaces(),
));
it('multiple non-whitespace characters', () => expectNonMatch(
// arrange
'abc',
// act
(act) => act.expectOptionalWhitespaces(),
));
});
});
describe('buildRegExp', () => {
it('sets global flag', () => {
@@ -134,84 +303,126 @@ describe('ExpressionRegexBuilder', () => {
expect(actual).to.equal(expected);
});
describe('can combine multiple parts', () => {
it('with', () => {
expectRegex(
(sut) => sut
// act
// {{ with $variable }}
.expectExpressionStart()
.expectCharacters('with')
.expectOneOrMoreWhitespaces()
.expectCharacters('$')
.matchUntilFirstWhitespace()
.expectExpressionEnd()
// scope
.matchMultilineAnythingExceptSurroundingWhitespaces()
// {{ end }}
.expectExpressionStart()
.expectCharacters('end')
.expectExpressionEnd(),
// assert
'{{\\s*with\\s+\\$([^|\\s]+)\\s*}}\\s*([\\S\\s]+?)\\s*{{\\s*end\\s*}}',
);
});
it('scoped substitution', () => {
expectRegex(
(sut) => sut
// act
.expectExpressionStart().expectCharacters('.')
.matchPipeline()
.expectExpressionEnd(),
// assert
'{{\\s*\\.\\s*(\\|\\s*.+?)?\\s*}}',
);
});
it('parameter substitution', () => {
expectRegex(
(sut) => sut
// act
.expectExpressionStart().expectCharacters('$')
.matchUntilFirstWhitespace()
.matchPipeline()
.expectExpressionEnd(),
// assert
'{{\\s*\\$([^|\\s]+)\\s*(\\|\\s*.+?)?\\s*}}',
);
});
it('combines character and whitespace expectations', () => expectMatch(
'abc def',
(act) => act
.expectCharacters('abc')
.expectOneOrMoreWhitespaces()
.expectCharacters('def'),
'abc def',
));
it('captures optional pipeline and text after it', () => expectCapture(
'abc | def',
(act) => act
.expectCharacters('abc ')
.captureOptionalPipeline(),
'| def',
));
it('combines multiline capture with optional whitespaces', () => expectCapture(
'\n abc \n',
(act) => act
.expectOptionalWhitespaces()
.captureMultilineAnythingExceptSurroundingWhitespaces()
.expectOptionalWhitespaces(),
'abc',
));
it('combines expression start, optional whitespaces, and character expectation', () => expectMatch(
'{{ abc',
(act) => act
.expectExpressionStart()
.expectOptionalWhitespaces()
.expectCharacters('abc'),
'{{ abc',
));
it('combines character expectation, optional whitespaces, and expression end', () => expectMatch(
'abc }}',
(act) => act
.expectCharacters('abc')
.expectOptionalWhitespaces()
.expectExpressionEnd(),
'abc }}',
));
});
});
});
function expectRegex(
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expected: string,
) {
enum MatchGroupIndex {
FullMatch = 0,
FirstCapturingGroup = 1,
}
function expectCapture(
input: string,
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedCombinedCaptures: string | undefined,
): void {
// arrange
const sut = new ExpressionRegexBuilder();
const matchGroupIndex = MatchGroupIndex.FirstCapturingGroup;
// act
const actual = act(sut).buildRegExp().source;
// assert
expect(actual).to.equal(expected);
expectMatch(input, act, expectedCombinedCaptures, matchGroupIndex);
}
function expectNonMatch(
input: string,
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
matchGroupIndex = MatchGroupIndex.FullMatch,
): void {
expectMatch(input, act, undefined, matchGroupIndex);
}
function expectNonCapture(
input: string,
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
): void {
expectNonMatch(input, act, MatchGroupIndex.FirstCapturingGroup);
}
function expectMatch(
input: string,
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedMatch: string,
) {
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedCombinedMatches: string | undefined,
matchGroupIndex = MatchGroupIndex.FullMatch,
): void {
// arrange
const [startMarker, endMarker] = [randomUUID(), randomUUID()];
const markedInput = `${startMarker}${input}${endMarker}`;
const builder = new ExpressionRegexBuilder()
.expectCharacters(startMarker);
act(builder);
const markedRegex = builder.expectCharacters(endMarker).buildRegExp();
const regexBuilder = new ExpressionRegexBuilder();
act(regexBuilder);
const regex = regexBuilder.buildRegExp();
// act
const match = Array.from(markedInput.matchAll(markedRegex))
.filter((matches) => matches.length > 1)
.map((matches) => matches[1])
.filter(Boolean)
.join();
const allMatchGroups = Array.from(input.matchAll(regex));
// assert
expect(match).to.equal(expectedMatch);
const actualMatches = allMatchGroups
.filter((matches) => matches.length > matchGroupIndex)
.map((matches) => matches[matchGroupIndex])
.filter(Boolean) // matchAll returns `""` for full matches, `null` for capture groups
.flat();
const actualCombinedMatches = actualMatches.length ? actualMatches.join('') : undefined;
expect(actualCombinedMatches).equal(
expectedCombinedMatches,
[
'\n\n---',
'Expected combined matches:',
getTestDataText(expectedCombinedMatches),
'Actual combined matches:',
getTestDataText(actualCombinedMatches),
'Input:',
getTestDataText(input),
'Regex:',
getTestDataText(regex.toString()),
'All match groups:',
getTestDataText(JSON.stringify(allMatchGroups)),
`Match index in group: ${matchGroupIndex}`,
'---\n\n',
].join('\n'),
);
}
function getTestDataText(data: string | undefined): string {
const outputPrefix = '\t> ';
if (data === undefined) {
return `${outputPrefix}undefined (no matches)`;
}
const getLiteralString = (text: string) => JSON.stringify(text).slice(1, -1);
const text = `${outputPrefix}\`${getLiteralString(data)}\``;
return text;
}

View File

@@ -4,25 +4,26 @@ import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressi
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub';
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
import { scrambledEqual } from '@/application/Common/Array';
export class SyntaxParserTestsRunner {
constructor(private readonly sut: IExpressionParser) {
}
public expectPosition(...testCases: IExpectPositionTestCase[]) {
public expectPosition(...testCases: ExpectPositionTestScenario[]) {
for (const testCase of testCases) {
it(testCase.name, () => {
// act
const expressions = this.sut.findExpressions(testCase.code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(testCase.expected);
expect(scrambledEqual(actual, testCase.expected));
});
}
return this;
}
public expectNoMatch(...testCases: INoMatchTestCase[]) {
public expectNoMatch(...testCases: NoMatchTestScenario[]) {
this.expectPosition(...testCases.map((testCase) => ({
name: testCase.name,
code: testCase.code,
@@ -30,7 +31,7 @@ export class SyntaxParserTestsRunner {
})));
}
public expectResults(...testCases: IExpectResultTestCase[]) {
public expectResults(...testCases: ExpectResultTestScenario[]) {
for (const testCase of testCases) {
it(testCase.name, () => {
// arrange
@@ -47,7 +48,21 @@ export class SyntaxParserTestsRunner {
return this;
}
public expectPipeHits(data: IExpectPipeHitTestData) {
public expectThrows(...testCases: ExpectThrowsTestScenario[]) {
for (const testCase of testCases) {
it(testCase.name, () => {
// arrange
const { expectedError } = testCase;
// act
const act = () => this.sut.findExpressions(testCase.code);
// assert
expect(act).to.throw(expectedError);
});
}
return this;
}
public expectPipeHits(data: ExpectPipeHitTestScenario) {
for (const validPipePart of PipeTestCases.ValidValues) {
this.expectHitPipePart(validPipePart, data);
}
@@ -56,7 +71,7 @@ export class SyntaxParserTestsRunner {
}
}
private expectHitPipePart(pipeline: string, data: IExpectPipeHitTestData) {
private expectHitPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
it(`"${pipeline}" hits`, () => {
// arrange
const expectedPipePart = pipeline.trim();
@@ -73,14 +88,14 @@ export class SyntaxParserTestsRunner {
// assert
expect(expressions).has.lengthOf(1);
expect(pipelineCompiler.compileHistory).has.lengthOf(1);
const actualPipeNames = pipelineCompiler.compileHistory[0].pipeline;
const actualPipePart = pipelineCompiler.compileHistory[0].pipeline;
const actualValue = pipelineCompiler.compileHistory[0].value;
expect(actualPipeNames).to.equal(expectedPipePart);
expect(actualPipePart).to.equal(expectedPipePart);
expect(actualValue).to.equal(data.parameterValue);
});
}
private expectMissPipePart(pipeline: string, data: IExpectPipeHitTestData) {
private expectMissPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
it(`"${pipeline}" misses`, () => {
// arrange
const args = new FunctionCallArgumentCollectionStub()
@@ -98,42 +113,51 @@ export class SyntaxParserTestsRunner {
});
}
}
interface IExpectResultTestCase {
name: string;
code: string;
args: (builder: FunctionCallArgumentCollectionStub) => FunctionCallArgumentCollectionStub;
expected: readonly string[];
interface ExpectResultTestScenario {
readonly name: string;
readonly code: string;
readonly args: (
builder: FunctionCallArgumentCollectionStub,
) => FunctionCallArgumentCollectionStub;
readonly expected: readonly string[];
}
interface IExpectPositionTestCase {
name: string;
code: string;
expected: readonly ExpressionPosition[];
interface ExpectThrowsTestScenario {
readonly name: string;
readonly code: string;
readonly expectedError: string;
}
interface INoMatchTestCase {
name: string;
code: string;
interface ExpectPositionTestScenario {
readonly name: string;
readonly code: string;
readonly expected: readonly ExpressionPosition[];
}
interface IExpectPipeHitTestData {
codeBuilder: (pipeline: string) => string;
parameterName: string;
parameterValue: string;
interface NoMatchTestScenario {
readonly name: string;
readonly code: string;
}
interface ExpectPipeHitTestScenario {
readonly codeBuilder: (pipeline: string) => string;
readonly parameterName: string;
readonly parameterValue: string;
}
const PipeTestCases = {
ValidValues: [
// Single pipe with different whitespace combinations
' | pipe1', ' |pipe1', '|pipe1', ' |pipe1', ' | pipe1',
' | pipe', ' |pipe', '|pipe', ' |pipe', ' | pipe',
// Double pipes with different whitespace combinations
' | pipe1 | pipe2', '| pipe1|pipe2', '|pipe1|pipe2', ' |pipe1 |pipe2', '| pipe1 | pipe2| pipe3 |pipe4',
// Wrong cases, but should match anyway and let pipelineCompiler throw errors
'| pip€', '| pip{e} ',
' | pipeFirst | pipeSecond', '| pipeFirst|pipeSecond', '|pipeFirst|pipeSecond', ' |pipeFirst |pipeSecond', '| pipeFirst | pipeSecond| pipeThird |pipeFourth',
],
InvalidValues: [
' pipe1 |pipe2', ' pipe1',
' withoutPipeBefore |pipe', ' withoutPipeBefore',
// It's OK to match them (move to valid values if needed) to let compiler throw instead.
'| pip€', '| pip{e} ', '| pipeWithNumber55', '| pipe with whitespace',
],
};

View File

@@ -7,15 +7,15 @@ import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
describe('WithParser', () => {
const sut = new WithParser();
const runner = new SyntaxParserTestsRunner(sut);
describe('finds as expected', () => {
describe('correctly identifies `with` syntax', () => {
runner.expectPosition(
{
name: 'when no scope is not used',
name: 'when no context variable is not used',
code: 'hello {{ with $parameter }}no usage{{ end }} here',
expected: [new ExpressionPosition(6, 44)],
},
{
name: 'when scope is used',
name: 'when context variable is used',
code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})',
expected: [new ExpressionPosition(11, 53)],
},
@@ -25,38 +25,70 @@ describe('WithParser', () => {
expected: [new ExpressionPosition(7, 51), new ExpressionPosition(61, 99)],
},
{
name: 'tolerate lack of whitespaces',
name: 'when nested',
code: 'outer: {{ with $outer }}outer value with context variable: {{ . }}, inner: {{ with $inner }}inner value{{ end }}.{{ end }}',
expected: [
/* outer: */ new ExpressionPosition(7, 122),
/* inner: */ new ExpressionPosition(77, 112),
],
},
{
name: 'whitespaces: tolerate lack of whitespaces',
code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}',
expected: [new ExpressionPosition(15, 55)],
},
{
name: 'match multiline text',
name: 'newlines: match multiline text',
code: 'non related line\n{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\n{{ end }}\nnon related line',
expected: [new ExpressionPosition(17, 92)],
},
{
name: 'newlines: does not match newlines before',
code: '\n{{ with $unimportant }}Text{{ end }}',
expected: [new ExpressionPosition(1, 37)],
},
{
name: 'newlines: does not match newlines after',
code: '{{ with $unimportant }}Text{{ end }}\n',
expected: [new ExpressionPosition(0, 36)],
},
);
});
describe('throws with incorrect `with` syntax', () => {
runner.expectThrows(
{
name: 'incorrect `with`: whitespace after dollar sign inside `with` statement',
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
expectedError: 'Context variable before `with` statement.',
},
{
name: 'incorrect `with`: whitespace before dollar sign inside `with` statement',
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
expectedError: 'Context variable before `with` statement.',
},
{
name: 'incorrect `with`: missing `with` statement',
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
expectedError: 'Context variable before `with` statement.',
},
{
name: 'incorrect `end`: missing `end` statement',
code: '{{ with $parameter}}value: {{ . }}{{ fin }}',
expectedError: 'Missing `end` statement, forgot `{{ end }}?',
},
{
name: 'incorrect `end`: used without `with`',
code: 'Value {{ end }}',
expectedError: 'Redundant `end` statement, missing `with`?',
},
{
name: 'incorrect "context variable": used without `with`',
code: 'Value: {{ . }}',
expectedError: 'Context variable before `with` statement.',
},
);
});
describe('ignores when syntax is wrong', () => {
describe('ignores expression if "with" syntax is wrong', () => {
runner.expectNoMatch(
{
name: 'does not tolerate whitespace after with',
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
},
{
name: 'does not tolerate whitespace before dollar',
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
},
{
name: 'wrong text at scope end',
code: '{{ with$parameter}}value: {{ . }}{{ fin }}',
},
{
name: 'wrong text at expression start',
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
},
);
});
describe('does not render argument if substitution syntax is wrong', () => {
runner.expectResults(
{
@@ -83,54 +115,73 @@ describe('WithParser', () => {
);
});
});
describe('renders scope conditionally', () => {
describe('does not render scope if argument is undefined', () => {
runner.expectResults(
...getAbsentStringTestCases().map((testCase) => ({
name: `does not render when value is "${testCase.valueName}"`,
code: '{{ with $parameter }}dark{{ end }} ',
args: (args) => args
.withArgument('parameter', testCase.absentValue),
expected: [''],
})),
{
name: 'does not render when argument is not provided',
code: '{{ with $parameter }}dark{{ end }}',
args: (args) => args,
expected: [''],
},
);
describe('scope rendering', () => {
describe('conditional rendering based on argument value', () => {
describe('does not render scope', () => {
runner.expectResults(
...getAbsentStringTestCases().map((testCase) => ({
name: `does not render when value is "${testCase.valueName}"`,
code: '{{ with $parameter }}dark{{ end }} ',
args: (args) => args
.withArgument('parameter', testCase.absentValue),
expected: [''],
})),
{
name: 'does not render when argument is not provided',
code: '{{ with $parameter }}dark{{ end }}',
args: (args) => args,
expected: [''],
},
);
});
describe('renders scope', () => {
runner.expectResults(
...getAbsentStringTestCases().map((testCase) => ({
name: `does not render when value is "${testCase.valueName}"`,
code: '{{ with $parameter }}dark{{ end }} ',
args: (args) => args
.withArgument('parameter', testCase.absentValue),
expected: [''],
})),
{
name: 'does not render when argument is not provided',
code: '{{ with $parameter }}dark{{ end }}',
args: (args) => args,
expected: [''],
},
{
name: 'renders scope even if value is not used',
code: '{{ with $parameter }}Hello world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when it has value',
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when whitespaces around brackets are missing',
code: '{{ with $parameter }}{{.}} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value multiple times when it\'s used multiple times',
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
args: (args) => args
.withArgument('letterL', 'l'),
expected: ['Hello world!'],
},
);
});
});
describe('render scope when variable has value', () => {
describe('whitespace handling inside scope', () => {
runner.expectResults(
{
name: 'renders scope even if value is not used',
code: '{{ with $parameter }}Hello world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when it has value',
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value when whitespaces around brackets are missing',
code: '{{ with $parameter }}{{.}} world!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello'),
expected: ['Hello world!'],
},
{
name: 'renders value multiple times when it\'s used multiple times',
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
args: (args) => args
.withArgument('letterL', 'l'),
expected: ['Hello world!'],
},
{
name: 'renders value in multi-lined text',
code: '{{ with $middleLine }}line before value\n{{ . }}\nline after value{{ end }}',
@@ -145,42 +196,71 @@ describe('WithParser', () => {
.withArgument('middleLine', 'value line'),
expected: ['line before value\nvalue line\nline after value'],
},
{
name: 'does not render trailing whitespace after value',
code: '{{ with $parameter }}{{ . }}! {{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render trailing newline after value',
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading newline before value',
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading whitespaces before value',
code: '{{ with $parameter }} {{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading newline and whitespaces before value',
code: '{{ with $parameter }}\r\n {{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
);
});
describe('nested with statements', () => {
runner.expectResults(
{
name: 'renders nested with statements correctly',
code: '{{ with $outer }}Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: {{ . }}{{ end }}',
args: (args) => args
.withArgument('outer', 'OuterValue')
.withArgument('inner', 'InnerValue'),
expected: [
'Inner: InnerValue',
'Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: OuterValue',
],
},
{
name: 'renders nested with statements with context variables',
code: '{{ with $outer }}{{ with $inner }}{{ . }}{{ . }}{{ end }}{{ . }}{{ end }}',
args: (args) => args
.withArgument('outer', 'O')
.withArgument('inner', 'I'),
expected: [
'II',
'{{ with $inner }}{{ . }}{{ . }}{{ end }}O',
],
},
);
});
});
describe('ignores trailing and leading whitespaces and newlines inside scope', () => {
runner.expectResults(
{
name: 'does not render trailing whitespace after value',
code: '{{ with $parameter }}{{ . }}! {{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render trailing newline after value',
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading newline before value',
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
{
name: 'does not render leading whitespace before value',
code: '{{ with $parameter }} {{ . }}!{{ end }}',
args: (args) => args
.withArgument('parameter', 'Hello world'),
expected: ['Hello world!'],
},
);
});
describe('compiles pipes in scope as expected', () => {
describe('pipe behavior', () => {
runner.expectPipeHits({
codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`,
parameterName: 'argument',

View File

@@ -59,7 +59,7 @@ describe('Script', () => {
describe('level', () => {
it('cannot construct with invalid wrong value', () => {
// arrange
const invalidValue: RecommendationLevel = 55;
const invalidValue: RecommendationLevel = 55 as never;
const expectedError = 'invalid level';
// act
const construct = () => new ScriptBuilder()

View File

@@ -22,7 +22,7 @@ describe('ScriptingDefinition', () => {
});
it('throws if unknown', () => {
// arrange
const unknownValue: ScriptingLanguage = 666;
const unknownValue: ScriptingLanguage = 666 as never;
const errorMessage = `unsupported language: ${unknownValue}`;
// act
const act = () => new ScriptingDefinitionBuilder()

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { BootstrapperStub } from '@tests/unit/shared/Stubs/BootstrapperStub';
import { ApplicationBootstrapper } from '@/presentation/bootstrapping/ApplicationBootstrapper';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import type { App } from 'vue';
describe('ApplicationBootstrapper', () => {
it('calls bootstrap on each bootstrapper', async () => {
// arrange
const bootstrapper1 = new BootstrapperStub();
const bootstrapper2 = new BootstrapperStub();
const sut = new ApplicationBootstrapper([bootstrapper1, bootstrapper2]);
// act
await sut.bootstrap({} as App);
// assert
expect(bootstrapper1.callHistory.map((c) => c.methodName === 'bootstrap')).to.have.lengthOf(1);
expect(bootstrapper2.callHistory.map((c) => c.methodName === 'bootstrap')).to.have.lengthOf(1);
});
it('calls bootstrap in the correct order', async () => {
// arrange
const callOrder: number[] = [];
const bootstrapper1 = {
async bootstrap(): Promise<void> {
callOrder.push(1);
},
};
const bootstrapper2 = {
async bootstrap(): Promise<void> {
callOrder.push(2);
},
};
const sut = new ApplicationBootstrapper([bootstrapper1, bootstrapper2]);
// act
await sut.bootstrap({} as App);
// assert
expect(callOrder).to.deep.equal([1, 2]);
});
it('stops if a bootstrapper fails', async () => {
// arrange
const expectedError = 'Bootstrap failed';
const bootstrapper1 = {
async bootstrap(): Promise<void> {
throw new Error(expectedError);
},
};
const bootstrapper2 = new BootstrapperStub();
const sut = new ApplicationBootstrapper([bootstrapper1, bootstrapper2]);
// act
const act = async () => { await sut.bootstrap({} as App); };
// assert
await expectThrowsAsync(act, expectedError);
expect(bootstrapper2.callHistory.map((c) => c.methodName === 'bootstrap')).to.have.lengthOf(0);
});
});

View File

@@ -14,6 +14,8 @@ describe('DependencyProvider', () => {
useApplication: createSingletonTests(),
useRuntimeEnvironment: createSingletonTests(),
useAutoUnsubscribedEvents: createTransientTests(),
useClipboard: createTransientTests(),
useCurrentCode: createTransientTests(),
};
Object.entries(testCases).forEach(([key, runTests]) => {
describe(`Key: "${key}"`, () => {

View File

@@ -3,13 +3,13 @@ import { AppInitializationLogger } from '@/presentation/bootstrapping/Modules/Ap
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
describe('AppInitializationLogger', () => {
it('logs the app initialization marker upon bootstrap', () => {
it('logs the app initialization marker upon bootstrap', async () => {
// arrange
const marker = '[APP_INIT]';
const loggerStub = new LoggerStub();
const sut = new AppInitializationLogger(loggerStub);
// act
sut.bootstrap();
await sut.bootstrap();
// assert
expect(loggerStub.callHistory).to.have.lengthOf(1);
expect(loggerStub.callHistory[0].args).to.have.lengthOf(1);

View File

@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
import { DependencyBootstrapper } from '@/presentation/bootstrapping/Modules/DependencyBootstrapper';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { VueDependencyInjectionApiStub } from '@tests/unit/shared/Stubs/VueDependencyInjectionApiStub';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
import type { App, inject } from 'vue';
describe('DependencyBootstrapper', () => {
describe('bootstrap', () => {
it('calls the contextFactory', async () => {
// arrange
const { mockContext, mockApp } = createMocks();
let contextFactoryCalled = false;
const sut = new DependencyBootstrapperBuilder()
.withContextFactory(async () => {
contextFactoryCalled = true;
return mockContext;
})
.build();
// act
await sut.bootstrap(mockApp);
// assert
expect(contextFactoryCalled).to.equal(true);
});
it('provides correct context to dependency provider', async () => {
// arrange
const { mockContext, mockApp } = createMocks();
const expectedContext = mockContext;
let actualContext: IApplicationContext | undefined;
const sut = new DependencyBootstrapperBuilder()
.withContextFactory(async () => expectedContext)
.withDependencyProvider((...params) => {
const [context] = params;
actualContext = context;
})
.build();
// act
await sut.bootstrap(mockApp);
// assert
expect(actualContext).to.equal(expectedContext);
});
it('provides correct provide function to dependency provider', async () => {
// arrange
const { mockApp, provideMock } = createMocks();
const expectedProvide = provideMock;
let actualProvide: typeof expectedProvide | undefined;
const sut = new DependencyBootstrapperBuilder()
.withDependencyProvider((...params) => {
actualProvide = params[1]?.provide;
})
.build();
// act
await sut.bootstrap(mockApp);
// assert
expect(actualProvide).to.equal(expectedProvide);
});
it('provides correct inject function to dependency provider', async () => {
// arrange
const { mockApp } = createMocks();
const expectedInjector = new VueDependencyInjectionApiStub().inject;
let actualInjector: Injector | undefined;
const sut = new DependencyBootstrapperBuilder()
.withInjector(expectedInjector)
.withDependencyProvider((...params) => {
actualInjector = params[1]?.inject;
})
.build();
// act
await sut.bootstrap(mockApp);
// assert
expect(actualInjector).to.equal(expectedInjector);
});
});
});
function createMocks() {
const provideMock = new VueDependencyInjectionApiStub().provide;
const mockContext = new ApplicationContextStub();
const mockApp = {
provide: provideMock,
} as unknown as App;
return { mockContext, mockApp, provideMock };
}
type Injector = typeof inject;
type Provider = typeof provideDependencies;
type ContextFactory = typeof buildContext;
class DependencyBootstrapperBuilder {
private contextFactory: ContextFactory = () => Promise.resolve(new ApplicationContextStub());
private dependencyProvider: Provider = () => new VueDependencyInjectionApiStub().provide;
private injector: Injector = () => new VueDependencyInjectionApiStub().inject;
public withContextFactory(contextFactory: ContextFactory): this {
this.contextFactory = contextFactory;
return this;
}
public withInjector(injector: Injector): this {
this.injector = injector;
return this;
}
public withDependencyProvider(dependencyProvider: Provider): this {
this.dependencyProvider = dependencyProvider;
return this;
}
public build(): DependencyBootstrapper {
return new DependencyBootstrapper(
this.contextFactory,
this.dependencyProvider,
this.injector,
);
}
}

View File

@@ -1,9 +1,10 @@
import { describe, it, expect } from 'vitest';
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
import { RuntimeSanityValidator } from '@/presentation/bootstrapping/Modules/RuntimeSanityValidator';
import { expectDoesNotThrowAsync, expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
describe('RuntimeSanityValidator', () => {
it('calls validator with correct options upon bootstrap', () => {
it('calls validator with correct options upon bootstrap', async () => {
// arrange
const expectedOptions: ISanityCheckOptions = {
validateEnvironmentVariables: true,
@@ -15,11 +16,11 @@ describe('RuntimeSanityValidator', () => {
};
const sut = new RuntimeSanityValidator(validatorMock);
// act
sut.bootstrap();
await sut.bootstrap();
// assert
expect(actualOptions).to.deep.equal(expectedOptions);
});
it('propagates the error if validator fails', () => {
it('propagates the error if validator fails', async () => {
// arrange
const expectedMessage = 'message thrown from validator';
const validatorMock = () => {
@@ -27,17 +28,17 @@ describe('RuntimeSanityValidator', () => {
};
const sut = new RuntimeSanityValidator(validatorMock);
// act
const act = () => sut.bootstrap();
const act = async () => { await sut.bootstrap(); };
// assert
expect(act).to.throw(expectedMessage);
await expectThrowsAsync(act, expectedMessage);
});
it('runs successfully if validator passes', () => {
it('runs successfully if validator passes', async () => {
// arrange
const validatorMock = () => { /* NOOP */ };
const sut = new RuntimeSanityValidator(validatorMock);
// act
const act = () => sut.bootstrap();
const act = async () => { await sut.bootstrap(); };
// assert
expect(act).to.not.throw();
await expectDoesNotThrowAsync(act);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import CodeCopyButton from '@/presentation/components/Code/CodeButtons/CodeCopyButton.vue';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
import { UseCurrentCodeStub } from '@tests/unit/shared/Stubs/UseCurrentCodeStub';
const COMPONENT_ICON_BUTTON_WRAPPER_NAME = 'IconButton';
describe('CodeCopyButton', () => {
it('copies current code when clicked', async () => {
// arrange
const expectedCode = 'code to be copied';
const clipboard = new ClipboardStub();
const wrapper = mountComponent({
clipboard,
currentCode: expectedCode,
});
// act
await wrapper.trigger('click');
// assert
const calls = clipboard.callHistory;
expect(calls).to.have.lengthOf(1);
const call = calls.find((c) => c.methodName === 'copyText');
expect(call).toBeDefined();
const [copiedText] = call.args;
expect(copiedText).to.equal(expectedCode);
});
});
function mountComponent(options?: {
clipboard?: Clipboard,
currentCode?: string,
}) {
return shallowMount(CodeCopyButton, {
global: {
provide: {
[InjectionKeys.useClipboard as symbol]: () => (
options?.clipboard
? new UseClipboardStub(options.clipboard)
: new UseClipboardStub()
).get(),
[InjectionKeys.useCurrentCode as symbol]: () => (
options.currentCode === undefined
? new UseCurrentCodeStub()
: new UseCurrentCodeStub().withCurrentCode(options.currentCode)
).get(),
},
stubs: {
[COMPONENT_ICON_BUTTON_WRAPPER_NAME]: {
name: COMPONENT_ICON_BUTTON_WRAPPER_NAME,
template: '<div @click="handleClick()" />',
emits: ['click'],
setup: (_, { emit }) => ({
handleClick: () => emit('click'),
}),
},
},
},
});
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import CodeInstruction from '@/presentation/components/Code/CodeButtons/Save/Instructions/CodeInstruction.vue';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
const DOM_SELECTOR_CODE_SLOT = 'code';
const DOM_SELECTOR_COPY_BUTTON = '.copy-button';
const COMPONENT_TOOLTIP_WRAPPER_NAME = 'TooltipWrapper';
describe('CodeInstruction.vue', () => {
it('renders a slot content inside a <code> element', () => {
// arrange
const expectedSlotContent = 'Example Code';
const wrapper = mountComponent({
slotContent: expectedSlotContent,
});
// act
const codeSlot = wrapper.find(DOM_SELECTOR_CODE_SLOT);
const actualContent = codeSlot.text();
// assert
expect(actualContent).to.equal(expectedSlotContent);
});
describe('copy', () => {
it('calls copyText when the copy button is clicked', async () => {
// arrange
const expectedCode = 'Code to be copied';
const clipboardStub = new ClipboardStub();
const wrapper = mountComponent({
clipboard: clipboardStub,
});
wrapper.vm.codeElement = { textContent: expectedCode } as HTMLElement;
// act
const copyButton = wrapper.find(DOM_SELECTOR_COPY_BUTTON);
await copyButton.trigger('click');
// assert
const calls = clipboardStub.callHistory;
expect(calls).to.have.lengthOf(1);
const call = calls.find((c) => c.methodName === 'copyText');
expect(call).toBeDefined();
const [actualCode] = call.args;
expect(actualCode).to.equal(expectedCode);
});
it('throws an error when codeElement is not found during copy', async () => {
// arrange
const expectedError = 'Code element could not be found.';
const wrapper = mountComponent();
wrapper.vm.codeElement = undefined;
// act
const act = () => wrapper.vm.copyCode();
// assert
await expectThrowsAsync(act, expectedError);
});
it('throws an error when codeElement has no textContent during copy', async () => {
// arrange
const expectedError = 'Code element does not contain any text.';
const wrapper = mountComponent();
wrapper.vm.codeElement = { textContent: '' } as HTMLElement;
// act
const act = () => wrapper.vm.copyCode();
// assert
await expectThrowsAsync(act, expectedError);
});
});
});
function mountComponent(options?: {
readonly clipboard?: Clipboard,
readonly slotContent?: string,
}) {
return shallowMount(CodeInstruction, {
global: {
provide: {
[InjectionKeys.useClipboard as symbol]:
() => {
if (options?.clipboard) {
return new UseClipboardStub(options.clipboard).get();
}
return new UseClipboardStub().get();
},
},
stubs: {
[COMPONENT_TOOLTIP_WRAPPER_NAME]: {
name: COMPONENT_TOOLTIP_WRAPPER_NAME,
template: '<slot />',
},
},
},
slots: {
default: options?.slotContent ?? 'Stubbed slot content',
},
});
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Instructions/InstructionListData';
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListData';
describe('InstructionsBuilder', () => {
describe('withStep', () => {

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