Compare commits

...

16 Commits

Author SHA1 Message Date
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
84 changed files with 3835 additions and 2184 deletions

View File

@@ -102,7 +102,7 @@ jobs:
- -
name: "App: Deploy to S3" name: "App: Deploy to S3"
shell: bash shell: bash
run: >- run: |-
declare web_output_dir declare web_output_dir
if ! web_output_dir=$(cd app && node scripts/print-dist-dir.js --web); then if ! web_output_dir=$(cd app && node scripts/print-dist-dir.js --web); then
echo 'Error: Could not determine distribution directory.' echo 'Error: Could not determine distribution directory.'

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. 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). 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'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). - 📖 If you wish to use templates, use [templating.md](./docs/templating.md).

View File

@@ -2,21 +2,69 @@
## Benefits of templating ## Benefits of templating
- Generating scripts by sharing code to increase best-practice usage and maintainability. - **Code sharing:** Share code across scripts for consistent practices and easier maintenance.
- Creating self-contained scripts without cross-dependencies. - **Script independence:** Generate self-contained scripts, eliminating the need for external code.
- Use of pipes for writing cleaner code and letting pipes do dirty work. - **Cleaner code:** Use pipes for complex operations, resulting in more readable and streamlined code.
## Expressions ## Expressions
- Expressions start and end with mustaches (double brackets, `{{` and `}}`). **Syntax:**
- E.g. `Hello {{ $name }} !`
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) but not the same. Expressions are enclosed within `{{` and `}}`.
- Functions enables usage of expressions. Example: `Hello {{ $name }}!`.
- In script definition parts of a function, see [`Function`](./collection-files.md#Function). They are a core component of templating, enhancing scripts with dynamic capabilities and functionality.
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
- Expressions inside expressions (nested templates) are supported. **Syntax similarity:**
- 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. 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 ```go
{{ with $condition }} {{ with $condition }}
@@ -26,55 +74,70 @@
### Parameter substitution ### Parameter substitution
A simple function example: Parameter substitution dynamically replaces variable references with their corresponding values in the script.
**Example function:**
```yaml ```yaml
function: EchoArgument name: DisplayTextFunction
parameters: parameters:
- name: 'argument' - name: 'text'
code: Hello {{ $argument }} ! code: echo {{ $text }}
``` ```
It would print "Hello world" if it's called in a [script](./collection-files.md#script) as following: Invoking `DisplayTextFunction` with `text` set to `"Hello, world!"` would result in `echo "Hello, World!"`.
```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 }} !
```
### with ### with
Skips its "block" if the variable is absent or empty. Its "block" is between `with` start (`{{ with .. }}`) and end (`{{ end }`}) expressions. The `with` expression enables conditional rendering and provides a context variable for simpler code.
E.g. `{{ with $parameterName }} Hi, I'm a block! {{ end }}` would only output `Hi, I'm a block!` if `parameterName` has any value..
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 ```go
{{ with $parameterName }}Parameter value is {{ . }} here {{ end }} {{ 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 ```go
{{ with $argument }} {{ with $argument }}
@@ -83,7 +146,9 @@ It supports multiline text inside the block. You can have something like:
{{ end }} {{ 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 ```go
{{ with $condition }} {{ with $condition }}
@@ -91,32 +156,44 @@ You can also use other expressions inside its block, such as [parameter substitu
{{ end }} {{ 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: ```go
{{ with $condition1 }}
```yaml Value of $condition1: {{ . }}
function: FunctionThatOutputsConditionally {{ with $condition2 }}
parameters: Value of $condition2: {{ . }}
- name: 'argument' {{ end }}
optional: true
code: |-
{{ with $argument }}
Value is: {{ . }}
{{ end }} {{ end }}
``` ```
### Pipes ### Pipes
- Pipes are functions available for handling text. Pipes are functions designed for text manipulation.
- Allows stacking actions one after another also known as "chaining". They allow for a sequential application of operations resembling [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), 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. 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. **Pre-defined**:
- ❗ Pipe names must be camelCase without any space or special characters.
- **Existing pipes** 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. - `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
- `escapeDoubleQuotes`: Escapes `"` characters, allows you to use them inside double quotes (`"`). - `escapeDoubleQuotes`: Escapes `"` characters for batch command execution, allows you to use them inside double quotes (`"`).
- **Example usages**
- `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}`
- `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}`

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 MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts');
const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts'); const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts');
const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html'); 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({ export default defineConfig({
main: getSharedElectronConfig({ main: getSharedElectronConfig({
distDirSubfolder: 'main', distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.main,
entryFilePath: MAIN_ENTRY_FILE, entryFilePath: MAIN_ENTRY_FILE,
}), }),
preload: getSharedElectronConfig({ preload: getSharedElectronConfig({
distDirSubfolder: 'preload', distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.preload,
entryFilePath: PRELOAD_ENTRY_FILE, entryFilePath: PRELOAD_ENTRY_FILE,
}), }),
renderer: mergeConfig( renderer: mergeConfig(
@@ -25,7 +31,7 @@ export default defineConfig({
}), }),
{ {
build: { build: {
outDir: resolve(DIST_DIR, 'renderer'), outDir: ELECTRON_DIST_SUBDIRECTORIES.renderer,
rollupOptions: { rollupOptions: {
input: { input: {
index: WEB_INDEX_HTML_PATH, index: WEB_INDEX_HTML_PATH,
@@ -42,7 +48,7 @@ function getSharedElectronConfig(options: {
}): UserConfig { }): UserConfig {
return { return {
build: { build: {
outDir: resolve(DIST_DIR, options.distDirSubfolder), outDir: options.distDirSubfolder,
lib: { lib: {
entry: options.entryFilePath, entry: options.entryFilePath,
}, },
@@ -64,6 +70,11 @@ function getSharedElectronConfig(options: {
}; };
} }
function resolvePathFromProjectRoot(pathSegment: string) { function resolvePathFromProjectRoot(pathSegment: string): string {
return resolve(__dirname, pathSegment); return resolve(__dirname, pathSegment);
} }
function resolveElectronDistSubdirectory(subDirectory: string): string {
const electronDistDir = resolvePathFromProjectRoot(distDirs.electronUnbundled);
return resolve(electronDistDir, subDirectory);
}

1179
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
"author": "undergroundwires", "author": "undergroundwires",
"type": "module", "type": "module",
"main": "./dist-electron-unbundled/main/index.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
@@ -43,7 +42,7 @@
"electron-updater": "^6.1.4", "electron-updater": "^6.1.4",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"markdown-it": "^13.0.2", "markdown-it": "^13.0.2",
"vue": "^2.7.14" "vue": "^3.3.7"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4", "@modyfi/vite-plugin-yaml": "^1.0.4",
@@ -53,17 +52,17 @@
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-legacy": "^4.1.1", "@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-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^1.3.6", "@vue/test-utils": "^2.4.1",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"cypress": "^13.3.1", "cypress": "^13.3.1",
"electron": "^27.0.0", "electron": "^27.0.0",
"electron-builder": "^24.6.4", "electron-builder": "^24.6.4",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1", "electron-icon-builder": "^2.0.1",
"electron-vite": "^1.0.27", "electron-vite": "^1.0.28",
"eslint": "^8.51.0", "eslint": "^8.51.0",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.17.0", "eslint-plugin-vue": "^9.17.0",
@@ -98,5 +97,11 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/undergroundwires/privacy.sexy.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+'); .addRawRegex('\\s+');
} }
public matchPipeline() { public captureOptionalPipeline() {
return this return this
.expectZeroOrMoreWhitespaces() .addRawRegex('((?:\\|\\s*\\b[a-zA-Z]+\\b\\s*)*)');
.addRawRegex('(\\|\\s*.+?)?');
} }
public matchUntilFirstWhitespace() { public captureUntilWhitespaceOrPipe() {
return this return this
.addRawRegex('([^|\\s]+)'); .addRawRegex('([^|\\s]+)');
} }
public matchMultilineAnythingExceptSurroundingWhitespaces() { public captureMultilineAnythingExceptSurroundingWhitespaces() {
return this return this
.expectZeroOrMoreWhitespaces() .expectOptionalWhitespaces()
.addRawRegex('([\\S\\s]+?)') .addRawRegex('([\\s\\S]*\\S)')
.expectZeroOrMoreWhitespaces(); .expectOptionalWhitespaces();
} }
public expectExpressionStart() { public expectExpressionStart() {
return this return this
.expectCharacters('{{') .expectCharacters('{{')
.expectZeroOrMoreWhitespaces(); .expectOptionalWhitespaces();
} }
public expectExpressionEnd() { public expectExpressionEnd() {
return this return this
.expectZeroOrMoreWhitespaces() .expectOptionalWhitespaces()
.expectCharacters('}}'); .expectCharacters('}}');
} }
public expectOptionalWhitespaces() {
return this
.addRawRegex('\\s*');
}
public buildRegExp(): RegExp { public buildRegExp(): RegExp {
return new RegExp(this.parts.join(''), 'g'); return new RegExp(this.parts.join(''), 'g');
} }
private expectZeroOrMoreWhitespaces() {
return this
.addRawRegex('\\s*');
}
private addRawRegex(regex: string) { private addRawRegex(regex: string) {
this.parts.push(regex); this.parts.push(regex);
return this; return this;

View File

@@ -6,8 +6,9 @@ export class ParameterSubstitutionParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder() protected readonly regex = new ExpressionRegexBuilder()
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('$') .expectCharacters('$')
.matchUntilFirstWhitespace() // First match: Parameter name .captureUntilWhitespaceOrPipe() // First capture: Parameter name
.matchPipeline() // Second match: Pipeline .expectOptionalWhitespaces()
.captureOptionalPipeline() // Second capture: Pipeline
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .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 { 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'; import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class WithParser extends RegexParser { export class WithParser implements IExpressionParser {
protected readonly regex = new ExpressionRegexBuilder() public findExpressions(code: string): IExpression[] {
// {{ with $parameterName }} if (!code) {
.expectExpressionStart() throw new Error('missing code');
.expectCharacters('with') }
.expectOneOrMoreWhitespaces() return parseWithExpressions(code);
.expectCharacters('$') }
.matchUntilFirstWhitespace() // First match: parameter name }
.expectExpressionEnd()
// ...
.matchMultilineAnythingExceptSurroundingWhitespaces() // Second match: Scope text
// {{ end }}
.expectExpressionStart()
.expectCharacters('end')
.expectExpressionEnd()
.buildRegExp();
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { enum WithStatementType {
const parameterName = match[1]; Start,
const scopeText = match[2]; 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 { return {
parameters: [new FunctionParameter(parameterName, true)], parameters,
evaluator: (context) => { position,
const argumentValue = context.args.hasArgument(parameterName) evaluate: (context) => {
? context.args.getArgument(parameterName).argumentValue const argumentValue = context.args.hasArgument(this.parameterName)
? context.args.getArgument(this.parameterName).argumentValue
: undefined; : undefined;
if (!argumentValue) { if (!argumentValue) {
return ''; return '';
} }
return replaceEachScopeSubstitution(scopeText, (pipeline) => { const substitutedScope = this.substituteContextVariables(scope, (pipeline) => {
if (!pipeline) { if (!pipeline) {
return argumentValue; return argumentValue;
} }
return context.pipelineCompiler.compile(argumentValue, pipeline); return context.pipelineCompiler.compile(argumentValue, pipeline);
}); });
return substitutedScope;
}, },
}; };
} }
constructor(
private readonly startExpressionPosition: ExpressionPosition,
private readonly parameterName: string,
) {
} }
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder() 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;
}
}
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 }} // {{ . | pipeName }}
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('.') .expectCharacters('.')
.matchPipeline() // First match: pipeline .expectOptionalWhitespaces()
.captureOptionalPipeline() // First capture: pipeline
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .buildRegExp();
function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) { const WithStatementStartRegEx = new ExpressionRegexBuilder()
// Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets, // {{ with $parameterName }}
// but instead letting the pipeline compiler to fail on those. .expectExpressionStart()
return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => { .expectCharacters('with')
return replacer(match1); .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 category: Clear Firefox history
docs: |- docs: |-
Mozilla Firefox, or simply Firefox, is a free and open-source web browser developed by the Mozilla Foundation and This category encompasses a series of scripts aimed at helping users manage and delete their browsing history and related data in Mozilla Firefox.
its subsidiary the Mozilla Corporation [1].
Firefox stores user-related data in user profiles [2]. 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.
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"
children: children:
- -
name: Clear Firefox cache name: Clear Firefox cache
@@ -755,9 +749,13 @@ actions:
# Snap installation # Snap installation
rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/* rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/*
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: crashes/ pathGlob: crashes/*
-
function: DeleteFilesFromFirefoxProfiles
parameters:
pathGlob: crashes/events/*
- -
name: Clear Firefox cookies name: Clear Firefox cookies
docs: |- 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" [1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
call: call:
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: cookies.sqlite pathGlob: cookies.sqlite
- -
name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.) 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: |- docs: |-
The file "places.sqlite" stores the annotations, bookmarks, favorite icons, input history, keywords, and browsing history (a record of visited pages) [1]. This script targets the Firefox browsing history, including URLs, downloads, bookmarks, and site visits, by deleting specific database entries.
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].
**Bookmarks**: Firefox stores various user data in a file named `places.sqlite`. This file includes:
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].
**Downloads:** - Annotations, bookmarks, and favorite icons (`moz_anno_attributes`, `moz_annos`, `moz_favicons`) [1]
Firefox downloads are stored in the 'places.sqlite' database, within the 'moz_annos' table [5]. - Browsing history, a record of pages visited (`moz_places`, `moz_historyvisits`) [1]
The entries in `moz_annos` are linked to `moz_places` that store the actual history entry (`moz_places.id = moz_annos.place_id`) [6]. - Keywords and typed URLs (`moz_keywords`, `moz_inputhistory`) [1]
Associated URL information is stored within the 'moz_places' table [5]. - Item annotations (`moz_items_annos`) [1]
Downloads have been historically stored in `downloads.rdf` for Firefox 2.x and below [7]. - Bookmark roots such as places, menu, toolbar, tags, unfiled (`moz_bookmarks_roots`) [1]
Starting with Firefox 3.x they're stored in `downloads.sqlite` [7].
**Favicons:** The `moz_places` table holds URL data, connecting to various other tables like `moz_annos`, `moz_bookmarks`, `moz_inputhistory`, and `moz_historyvisits` [2].
Firefox favicons are stored in the `favicons.sqlite` database, within the `moz_icons` table [5]. Due to these connections, the script removes entries from all relevant tables simultaneously to maintain database integrity.
Older versions of Firefox stored Favicons in the 'places.sqlite' database, within the `moz_favicons` table [5].
**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" [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" [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" [7]: https://web.archive.org/web/20221029145712/https://kb.mozillazine.org/Downloads.rdf "Downloads.rdf | MozillaZine Knowledge Base | kb.mozillazine.org"
call: call:
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: downloads.rdf pathGlob: downloads.rdf
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: downloads.sqlite pathGlob: downloads.sqlite
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: places.sqlite pathGlob: places.sqlite
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: favicons.sqlite pathGlob: favicons.sqlite
- -
name: Clear Firefox logins name: Clear Firefox logins
docs: |- 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" [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: call:
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: logins.json pathGlob: logins.json
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: logins-backup.json pathGlob: logins-backup.json
- -
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: signons.sqlite pathGlob: signons.sqlite
- -
name: Clear Firefox autocomplete history name: Clear Firefox autocomplete history
docs: |- 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" [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: call:
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: formhistory.sqlite pathGlob: formhistory.sqlite
- -
name: Clear Firefox "Multi-Account Containers" data name: Clear Firefox "Multi-Account Containers" data
docs: |- 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" [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: call:
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: containers.json pathGlob: containers.json
- -
name: Clear Firefox open tabs and windows data name: Clear Firefox open tabs and windows data
docs: |- 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" [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: call:
function: DeleteFromFirefoxProfiles function: DeleteFilesFromFirefoxProfiles
parameters: parameters:
path: sessionstore.jsonlz4 pathGlob: sessionstore.jsonlz4
- -
category: Clear system and kernel usage data category: Clear system and kernel usage data
docs: |- docs: |-
@@ -2911,7 +2905,8 @@ actions:
function: AddFirefoxPrefs function: AddFirefoxPrefs
parameters: parameters:
prefName: toolkit.telemetry.log.level prefName: toolkit.telemetry.log.level
jsonValue: 'Fatal' jsonValue: >-
"Fatal"
- -
name: Disable Firefox telemetry log output name: Disable Firefox telemetry log output
recommend: standard recommend: standard
@@ -2924,7 +2919,8 @@ actions:
function: AddFirefoxPrefs function: AddFirefoxPrefs
parameters: parameters:
prefName: toolkit.telemetry.log.dump prefName: toolkit.telemetry.log.dump
jsonValue: 'Fatal' jsonValue: >-
"Fatal"
- -
name: Clear Firefox telemetry user ID name: Clear Firefox telemetry user ID
recommend: standard recommend: standard
@@ -3491,16 +3487,66 @@ functions:
>&2 echo "Failed, $service does not exist." >&2 echo "Failed, $service does not exist."
fi 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: 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: parameters:
- name: path # file or folder in profile file
code: |- code: |-
# {{ $path }}: Global installation import glob
rm -rfv ~/.mozilla/firefox/*/{{ $path }} import os
# {{ $path }}: Flatpak installation path = '{{ $fileGlob }}'
rm -rfv ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $path }} expanded_path = os.path.expandvars(os.path.expanduser(path))
# {{ $path }}: Snap installation print(f'Deleting files matching pattern: {expanded_path}')
rm -rfv ~/snap/firefox/common/.mozilla/firefox/*/{{ $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 name: CleanTableFromFirefoxProfileDatabase
parameters: parameters:

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,20 +1,23 @@
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper'; import { Bootstrapper } from './Bootstrapper';
import { VueBootstrapper } from './Modules/VueBootstrapper';
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator'; import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
import { AppInitializationLogger } from './Modules/AppInitializationLogger'; import { AppInitializationLogger } from './Modules/AppInitializationLogger';
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
import type { App } from 'vue';
export class ApplicationBootstrapper implements IVueBootstrapper { export class ApplicationBootstrapper implements Bootstrapper {
public bootstrap(vue: VueConstructor): void { constructor(private readonly bootstrappers = ApplicationBootstrapper.getAllBootstrappers()) { }
const bootstrappers = ApplicationBootstrapper.getAllBootstrappers();
for (const bootstrapper of bootstrappers) { public async bootstrap(app: App): Promise<void> {
bootstrapper.bootstrap(vue); 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 [ return [
new VueBootstrapper(),
new RuntimeSanityValidator(), new RuntimeSanityValidator(),
new DependencyBootstrapper(),
new AppInitializationLogger(), new AppInitializationLogger(),
]; ];
} }

View File

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

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 { ILogger } from '@/infrastructure/Log/ILogger';
import { IVueBootstrapper } from '../IVueBootstrapper'; import { Bootstrapper } from '../Bootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory'; import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements IVueBootstrapper { export class AppInitializationLogger implements Bootstrapper {
constructor( constructor(
private readonly logger: ILogger = ClientLoggerFactory.Current.logger, 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. // Do not remove [APP_INIT]; it's a marker used in tests.
this.logger.info('[APP_INIT] Application is initialized.'); 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 { 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) { constructor(private readonly validator = validateRuntimeSanity) {
} }
public bootstrap(): void { public async bootstrap(): Promise<void> {
this.validator({ this.validator({
validateEnvironmentVariables: true, validateEnvironmentVariables: true,
validateWindowVariables: 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 TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue'; import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.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' const OptionalDevToolkit = process.env.NODE_ENV !== 'production'
? defineAsyncComponent(() => import('@/presentation/components/DevToolkit/DevToolkit.vue')) ? defineAsyncComponent(() => import('@/presentation/components/DevToolkit/DevToolkit.vue'))
@@ -36,9 +32,7 @@ export default defineComponent({
TheFooter, TheFooter,
OptionalDevToolkit, OptionalDevToolkit,
}, },
setup() { setup() { },
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
},
}); });
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<span class="code-wrapper"> <span class="code-wrapper">
<span class="dollar">$</span> <span class="dollar">$</span>
<code><slot /></code> <code ref="codeElement"><slot /></code>
<TooltipWrapper> <TooltipWrapper>
<AppIcon <AppIcon
class="copy-button" class="copy-button"
@@ -16,7 +16,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, useSlots } from 'vue'; import { defineComponent, shallowRef } from 'vue';
import { Clipboard } from '@/infrastructure/Clipboard'; import { Clipboard } from '@/infrastructure/Clipboard';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue'; import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
@@ -27,15 +27,23 @@ export default defineComponent({
AppIcon, AppIcon,
}, },
setup() { setup() {
const slots = useSlots(); const codeElement = shallowRef<HTMLElement | undefined>();
function copyCode() { function copyCode() {
const code = slots.default()[0].text; 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.');
}
Clipboard.copyText(code); Clipboard.copyText(code);
} }
return { return {
copyCode, copyCode,
codeElement,
}; };
}, },
}); });

View File

@@ -185,7 +185,8 @@ function getDefaultCode(language: ScriptingLanguage): string {
<style scoped lang="scss"> <style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *; @use "@/presentation/assets/styles/main" as *;
::v-deep .code-area { :deep() {
.code-area {
min-height: 200px; min-height: 200px;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -195,4 +196,5 @@ function getDefaultCode(language: ScriptingLanguage): string {
position: absolute; position: absolute;
} }
} }
}
</style> </style>

View File

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

View File

@@ -1,5 +1,9 @@
<template> <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 <span
v-bind:class="{ v-bind:class="{
disabled: !enabled, disabled: !enabled,

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,14 +2,6 @@ import type { ReadOnlyTreeNode } from '../Node/TreeNode';
export interface TreeViewFilterEvent { export interface TreeViewFilterEvent {
readonly action: TreeViewFilterAction; 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; readonly predicate?: TreeViewFilterPredicate;
} }
@@ -25,7 +17,6 @@ export function createFilterTriggeredEvent(
): TreeViewFilterEvent { ): TreeViewFilterEvent {
return { return {
action: TreeViewFilterAction.Triggered, action: TreeViewFilterAction.Triggered,
timestamp: new Date(),
predicate, predicate,
}; };
} }
@@ -33,6 +24,5 @@ export function createFilterTriggeredEvent(
export function createFilterRemovedEvent(): TreeViewFilterEvent { export function createFilterRemovedEvent(): TreeViewFilterEvent {
return { return {
action: TreeViewFilterAction.Removed, action: TreeViewFilterAction.Removed,
timestamp: new Date(),
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { import {
computed, inject, readonly, ref, computed, inject, shallowReadonly, shallowRef,
} from 'vue'; } from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols'; import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
@@ -15,7 +15,7 @@ export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
}); });
return { return {
selectedScriptNodeIds: readonly(selectedNodeIds), selectedScriptNodeIds: shallowReadonly(selectedNodeIds),
}; };
} }
@@ -23,7 +23,7 @@ function useSelectedScripts() {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)(); const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { onStateChange } = inject(InjectionKeys.useCollectionState)(); const { onStateChange } = inject(InjectionKeys.useCollectionState)();
const selectedScripts = ref<readonly SelectedScript[]>([]); const selectedScripts = shallowRef<readonly SelectedScript[]>([]);
onStateChange((state) => { onStateChange((state) => {
selectedScripts.value = state.selection.selectedScripts; selectedScripts.value = state.selection.selectedScripts;
@@ -35,6 +35,6 @@ function useSelectedScripts() {
}, { immediate: true }); }, { immediate: true });
return { return {
selectedScripts: readonly(selectedScripts), selectedScripts: shallowReadonly(selectedScripts),
}; };
} }

View File

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

View File

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

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

@@ -30,7 +30,7 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.inline-icon { .inline-icon {
display: inline-block; 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; display: inline-block;
height: 1em; height: 1em;
overflow: visible; overflow: visible;

View File

@@ -1,5 +1,5 @@
import { import {
WatchSource, readonly, ref, watch, WatchSource, shallowReadonly, ref, watch,
} from 'vue'; } from 'vue';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy'; import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IconName } from './IconName'; import { IconName } from './IconName';
@@ -15,7 +15,7 @@ export function useSvgLoader(
}, { immediate: true }); }, { immediate: true });
return { 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 { function modifySvg(svgSource: string): string {
const parser = new DOMParser(); const parser = new window.DOMParser();
const doc = parser.parseFromString(svgSource, 'image/svg+xml'); const doc = parser.parseFromString(svgSource, 'image/svg+xml');
let svgRoot = doc.documentElement; let svgRoot = doc.documentElement;
svgRoot = removeSvgComments(svgRoot); svgRoot = removeSvgComments(svgRoot);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import Vue from 'vue'; import { createApp } from 'vue';
import App from './components/App.vue'; import App from './components/App.vue';
import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper'; import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper';
new ApplicationBootstrapper() const app = createApp(App);
.bootstrap(Vue);
new Vue({ await new ApplicationBootstrapper()
render: (h) => h(App), .bootstrap(app);
}).$mount('#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, describe, it, expect,
} from 'vitest'; } from 'vitest';
import { mount } from '@vue/test-utils'; 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 TreeView from '@/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue';
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData'; import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider'; import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
@@ -33,8 +33,8 @@ function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) {
setup() { setup() {
provideDependencies(new ApplicationContextStub()); provideDependencies(new ApplicationContextStub());
const initialNodes = ref(initialNodeData); const initialNodes = shallowRef(initialNodeData);
const selectedLeafNodeIds = ref<readonly string[]>([]); const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
return { return {
initialNodes, initialNodes,
selectedLeafNodeIds, selectedLeafNodeIds,

View File

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

View File

@@ -1,126 +1,295 @@
import { randomUUID } from 'crypto';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ExpressionRegexBuilder } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder'; import { ExpressionRegexBuilder } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder';
const AllWhitespaceCharacters = ' \t\n\r\v\f\u00A0';
describe('ExpressionRegexBuilder', () => { describe('ExpressionRegexBuilder', () => {
describe('expectCharacters', () => { describe('expectCharacters', () => {
describe('escape single as expected', () => { describe('expectCharacters', () => {
describe('escapes single character as expected', () => {
const charactersToEscape = ['.', '$']; const charactersToEscape = ['.', '$'];
for (const character of charactersToEscape) { for (const character of charactersToEscape) {
it(character, () => { it(`escapes ${character} as expected`, () => expectMatch(
expectRegex( character,
// act
(act) => act.expectCharacters(character), (act) => act.expectCharacters(character),
// assert `${character}`,
`\\${character}`, ));
);
});
} }
}); });
it('escapes multiple as expected', () => { it('escapes multiple characters as expected', () => expectMatch(
expectRegex( '.I have no $$.',
// act
(act) => act.expectCharacters('.I have no $$.'), (act) => act.expectCharacters('.I have no $$.'),
// assert '.I have no $$.',
'\\.I have no \\$\\$\\.', ));
); it('adds characters as expected', () => expectMatch(
});
it('adds as expected', () => {
expectRegex(
// act
(act) => act.expectCharacters('return as it is'),
// assert
'return as it is', 'return as it is',
); (act) => act.expectCharacters('return as it is'),
'return as it is',
));
}); });
}); });
it('expectOneOrMoreWhitespaces', () => { describe('expectOneOrMoreWhitespaces', () => {
expectRegex( it('matches one whitespace', () => expectMatch(
// act ' ',
(act) => act.expectOneOrMoreWhitespaces(), (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', () => { describe('captureOptionalPipeline', () => {
expectRegex( it('does not capture when no pipe is present', () => expectNonMatch(
// act 'noPipeHere',
(act) => act.matchPipeline(), (act) => act.captureOptionalPipeline(),
// assert ));
'\\s*(\\|\\s*.+?)?', 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( describe('captureUntilWhitespaceOrPipe', () => {
// act it('captures until first whitespace', () => expectCapture(
(act) => act.matchUntilFirstWhitespace(),
// assert
'([^|\\s]+)',
);
it('matches until first whitespace', () => expectMatch(
// arrange // arrange
'first second', 'first ',
// act // act
(act) => act.matchUntilFirstWhitespace(), (act) => act.captureUntilWhitespaceOrPipe(),
// assert // assert
'first', 'first',
)); ));
it('captures until first pipe', () => expectCapture(
// arrange
'first|',
// act
(act) => act.captureUntilWhitespaceOrPipe(),
// assert
'first',
));
it('captures all without whitespace or pipe', () => expectCapture(
// arrange
'all',
// act
(act) => act.captureUntilWhitespaceOrPipe(),
// assert
'all',
));
}); });
describe('matchMultilineAnythingExceptSurroundingWhitespaces', () => { describe('captureMultilineAnythingExceptSurroundingWhitespaces', () => {
it('returns expected regex', () => expectRegex( describe('single line', () => {
// act it('captures a line without surrounding whitespaces', () => expectCapture(
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'\\s*([\\S\\s]+?)\\s*',
));
it('matches single line', () => expectMatch(
// arrange // arrange
'single line', 'line',
// act // act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(), (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 // assert
'single line', 'single line',
)); ));
it('matches single line without surrounding whitespaces', () => expectMatch( });
describe('multiple lines', () => {
it('captures text across multiple lines', () => expectCapture(
// arrange // arrange
' single line\t', 'first line\nsecond line\r\nthird-line',
// act // act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(), (act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
// assert // assert
'single line', 'first line\nsecond line\r\nthird-line',
)); ));
it('matches multiple lines', () => expectMatch( it('captures text with empty lines in between', () => expectCapture(
// arrange 'start\n\nend',
'first line\nsecond line', (act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
// act 'start\n\nend',
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'first line\nsecond line',
)); ));
it('matches multiple lines without surrounding whitespaces', () => expectMatch( it('excludes surrounding whitespaces from multiline text', () => expectCapture(
// arrange // arrange
' first line\nsecond line\t', ` first line\nsecond line${AllWhitespaceCharacters}`,
// act // act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(), (act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
// assert // assert
'first line\nsecond line', 'first line\nsecond line',
)); ));
}); });
it('expectExpressionStart', () => { describe('edge cases', () => {
expectRegex( it('does not capture for input with only whitespaces', () => expectNonCapture(
// act AllWhitespaceCharacters,
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
));
});
});
describe('expectExpressionStart', () => {
it('matches expression start without trailing whitespaces', () => expectMatch(
'{{expression',
(act) => act.expectExpressionStart(), (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', () => { describe('expectExpressionEnd', () => {
expectRegex( it('matches expression end without preceding whitespaces', () => expectMatch(
// act 'expression}}',
(act) => act.expectExpressionEnd(), (act) => act.expectExpressionEnd(),
'}}',
));
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 // assert
'\\s*}}', '\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', () => { describe('buildRegExp', () => {
it('sets global flag', () => { it('sets global flag', () => {
@@ -134,84 +303,126 @@ describe('ExpressionRegexBuilder', () => {
expect(actual).to.equal(expected); expect(actual).to.equal(expected);
}); });
describe('can combine multiple parts', () => { describe('can combine multiple parts', () => {
it('with', () => { it('combines character and whitespace expectations', () => expectMatch(
expectRegex( 'abc def',
(sut) => sut (act) => act
// act .expectCharacters('abc')
// {{ with $variable }}
.expectExpressionStart()
.expectCharacters('with')
.expectOneOrMoreWhitespaces() .expectOneOrMoreWhitespaces()
.expectCharacters('$') .expectCharacters('def'),
.matchUntilFirstWhitespace() 'abc def',
.expectExpressionEnd() ));
// scope it('captures optional pipeline and text after it', () => expectCapture(
.matchMultilineAnythingExceptSurroundingWhitespaces() 'abc | def',
// {{ end }} (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() .expectExpressionStart()
.expectCharacters('end') .expectOptionalWhitespaces()
.expectCharacters('abc'),
'{{ abc',
));
it('combines character expectation, optional whitespaces, and expression end', () => expectMatch(
'abc }}',
(act) => act
.expectCharacters('abc')
.expectOptionalWhitespaces()
.expectExpressionEnd(), .expectExpressionEnd(),
// assert 'abc }}',
'{{\\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*}}',
);
});
}); });
}); });
}); });
function expectRegex( enum MatchGroupIndex {
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder, FullMatch = 0,
expected: string, FirstCapturingGroup = 1,
) { }
function expectCapture(
input: string,
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedCombinedCaptures: string | undefined,
): void {
// arrange // arrange
const sut = new ExpressionRegexBuilder(); const matchGroupIndex = MatchGroupIndex.FirstCapturingGroup;
// act // act
const actual = act(sut).buildRegExp().source;
// assert // 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( function expectMatch(
input: string, input: string,
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder, act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedMatch: string, expectedCombinedMatches: string | undefined,
) { matchGroupIndex = MatchGroupIndex.FullMatch,
): void {
// arrange // arrange
const [startMarker, endMarker] = [randomUUID(), randomUUID()]; const regexBuilder = new ExpressionRegexBuilder();
const markedInput = `${startMarker}${input}${endMarker}`; act(regexBuilder);
const builder = new ExpressionRegexBuilder() const regex = regexBuilder.buildRegExp();
.expectCharacters(startMarker);
act(builder);
const markedRegex = builder.expectCharacters(endMarker).buildRegExp();
// act // act
const match = Array.from(markedInput.matchAll(markedRegex)) const allMatchGroups = Array.from(input.matchAll(regex));
.filter((matches) => matches.length > 1)
.map((matches) => matches[1])
.filter(Boolean)
.join();
// assert // 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 { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub'; import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub';
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub'; import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
import { scrambledEqual } from '@/application/Common/Array';
export class SyntaxParserTestsRunner { export class SyntaxParserTestsRunner {
constructor(private readonly sut: IExpressionParser) { constructor(private readonly sut: IExpressionParser) {
} }
public expectPosition(...testCases: IExpectPositionTestCase[]) { public expectPosition(...testCases: ExpectPositionTestScenario[]) {
for (const testCase of testCases) { for (const testCase of testCases) {
it(testCase.name, () => { it(testCase.name, () => {
// act // act
const expressions = this.sut.findExpressions(testCase.code); const expressions = this.sut.findExpressions(testCase.code);
// assert // assert
const actual = expressions.map((e) => e.position); const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(testCase.expected); expect(scrambledEqual(actual, testCase.expected));
}); });
} }
return this; return this;
} }
public expectNoMatch(...testCases: INoMatchTestCase[]) { public expectNoMatch(...testCases: NoMatchTestScenario[]) {
this.expectPosition(...testCases.map((testCase) => ({ this.expectPosition(...testCases.map((testCase) => ({
name: testCase.name, name: testCase.name,
code: testCase.code, code: testCase.code,
@@ -30,7 +31,7 @@ export class SyntaxParserTestsRunner {
}))); })));
} }
public expectResults(...testCases: IExpectResultTestCase[]) { public expectResults(...testCases: ExpectResultTestScenario[]) {
for (const testCase of testCases) { for (const testCase of testCases) {
it(testCase.name, () => { it(testCase.name, () => {
// arrange // arrange
@@ -47,7 +48,21 @@ export class SyntaxParserTestsRunner {
return this; 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) { for (const validPipePart of PipeTestCases.ValidValues) {
this.expectHitPipePart(validPipePart, data); 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`, () => { it(`"${pipeline}" hits`, () => {
// arrange // arrange
const expectedPipePart = pipeline.trim(); const expectedPipePart = pipeline.trim();
@@ -73,14 +88,14 @@ export class SyntaxParserTestsRunner {
// assert // assert
expect(expressions).has.lengthOf(1); expect(expressions).has.lengthOf(1);
expect(pipelineCompiler.compileHistory).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; const actualValue = pipelineCompiler.compileHistory[0].value;
expect(actualPipeNames).to.equal(expectedPipePart); expect(actualPipePart).to.equal(expectedPipePart);
expect(actualValue).to.equal(data.parameterValue); expect(actualValue).to.equal(data.parameterValue);
}); });
} }
private expectMissPipePart(pipeline: string, data: IExpectPipeHitTestData) { private expectMissPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
it(`"${pipeline}" misses`, () => { it(`"${pipeline}" misses`, () => {
// arrange // arrange
const args = new FunctionCallArgumentCollectionStub() const args = new FunctionCallArgumentCollectionStub()
@@ -98,42 +113,51 @@ export class SyntaxParserTestsRunner {
}); });
} }
} }
interface IExpectResultTestCase {
name: string; interface ExpectResultTestScenario {
code: string; readonly name: string;
args: (builder: FunctionCallArgumentCollectionStub) => FunctionCallArgumentCollectionStub; readonly code: string;
expected: readonly string[]; readonly args: (
builder: FunctionCallArgumentCollectionStub,
) => FunctionCallArgumentCollectionStub;
readonly expected: readonly string[];
} }
interface IExpectPositionTestCase { interface ExpectThrowsTestScenario {
name: string; readonly name: string;
code: string; readonly code: string;
expected: readonly ExpressionPosition[]; readonly expectedError: string;
} }
interface INoMatchTestCase { interface ExpectPositionTestScenario {
name: string; readonly name: string;
code: string; readonly code: string;
readonly expected: readonly ExpressionPosition[];
} }
interface IExpectPipeHitTestData { interface NoMatchTestScenario {
codeBuilder: (pipeline: string) => string; readonly name: string;
parameterName: string; readonly code: string;
parameterValue: string; }
interface ExpectPipeHitTestScenario {
readonly codeBuilder: (pipeline: string) => string;
readonly parameterName: string;
readonly parameterValue: string;
} }
const PipeTestCases = { const PipeTestCases = {
ValidValues: [ ValidValues: [
// Single pipe with different whitespace combinations // Single pipe with different whitespace combinations
' | pipe1', ' |pipe1', '|pipe1', ' |pipe1', ' | pipe1', ' | pipe', ' |pipe', '|pipe', ' |pipe', ' | pipe',
// Double pipes with different whitespace combinations // Double pipes with different whitespace combinations
' | pipe1 | pipe2', '| pipe1|pipe2', '|pipe1|pipe2', ' |pipe1 |pipe2', '| pipe1 | pipe2| pipe3 |pipe4', ' | pipeFirst | pipeSecond', '| pipeFirst|pipeSecond', '|pipeFirst|pipeSecond', ' |pipeFirst |pipeSecond', '| pipeFirst | pipeSecond| pipeThird |pipeFourth',
// Wrong cases, but should match anyway and let pipelineCompiler throw errors
'| pip€', '| pip{e} ',
], ],
InvalidValues: [ 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', () => { describe('WithParser', () => {
const sut = new WithParser(); const sut = new WithParser();
const runner = new SyntaxParserTestsRunner(sut); const runner = new SyntaxParserTestsRunner(sut);
describe('finds as expected', () => { describe('correctly identifies `with` syntax', () => {
runner.expectPosition( 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', code: 'hello {{ with $parameter }}no usage{{ end }} here',
expected: [new ExpressionPosition(6, 44)], expected: [new ExpressionPosition(6, 44)],
}, },
{ {
name: 'when scope is used', name: 'when context variable is used',
code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})', code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})',
expected: [new ExpressionPosition(11, 53)], expected: [new ExpressionPosition(11, 53)],
}, },
@@ -25,38 +25,70 @@ describe('WithParser', () => {
expected: [new ExpressionPosition(7, 51), new ExpressionPosition(61, 99)], 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}}', code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}',
expected: [new ExpressionPosition(15, 55)], 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', code: 'non related line\n{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\n{{ end }}\nnon related line',
expected: [new ExpressionPosition(17, 92)], 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 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', () => { describe('does not render argument if substitution syntax is wrong', () => {
runner.expectResults( runner.expectResults(
{ {
@@ -83,8 +115,9 @@ describe('WithParser', () => {
); );
}); });
}); });
describe('renders scope conditionally', () => { describe('scope rendering', () => {
describe('does not render scope if argument is undefined', () => { describe('conditional rendering based on argument value', () => {
describe('does not render scope', () => {
runner.expectResults( runner.expectResults(
...getAbsentStringTestCases().map((testCase) => ({ ...getAbsentStringTestCases().map((testCase) => ({
name: `does not render when value is "${testCase.valueName}"`, name: `does not render when value is "${testCase.valueName}"`,
@@ -101,8 +134,21 @@ describe('WithParser', () => {
}, },
); );
}); });
describe('render scope when variable has value', () => { describe('renders scope', () => {
runner.expectResults( 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', name: 'renders scope even if value is not used',
code: '{{ with $parameter }}Hello world!{{ end }}', code: '{{ with $parameter }}Hello world!{{ end }}',
@@ -131,6 +177,11 @@ describe('WithParser', () => {
.withArgument('letterL', 'l'), .withArgument('letterL', 'l'),
expected: ['Hello world!'], expected: ['Hello world!'],
}, },
);
});
});
describe('whitespace handling inside scope', () => {
runner.expectResults(
{ {
name: 'renders value in multi-lined text', name: 'renders value in multi-lined text',
code: '{{ with $middleLine }}line before value\n{{ . }}\nline after value{{ end }}', code: '{{ with $middleLine }}line before value\n{{ . }}\nline after value{{ end }}',
@@ -145,11 +196,6 @@ describe('WithParser', () => {
.withArgument('middleLine', 'value line'), .withArgument('middleLine', 'value line'),
expected: ['line before value\nvalue line\nline after value'], expected: ['line before value\nvalue line\nline after value'],
}, },
);
});
});
describe('ignores trailing and leading whitespaces and newlines inside scope', () => {
runner.expectResults(
{ {
name: 'does not render trailing whitespace after value', name: 'does not render trailing whitespace after value',
code: '{{ with $parameter }}{{ . }}! {{ end }}', code: '{{ with $parameter }}{{ . }}! {{ end }}',
@@ -172,15 +218,49 @@ describe('WithParser', () => {
expected: ['Hello world!'], expected: ['Hello world!'],
}, },
{ {
name: 'does not render leading whitespace before value', name: 'does not render leading whitespaces before value',
code: '{{ with $parameter }} {{ . }}!{{ end }}', code: '{{ with $parameter }} {{ . }}!{{ end }}',
args: (args) => args args: (args) => args
.withArgument('parameter', 'Hello world'), .withArgument('parameter', 'Hello world'),
expected: ['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('compiles pipes in scope as expected', () => { 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('pipe behavior', () => {
runner.expectPipeHits({ runner.expectPipeHits({
codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`, codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`,
parameterName: 'argument', parameterName: 'argument',

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

@@ -3,13 +3,13 @@ import { AppInitializationLogger } from '@/presentation/bootstrapping/Modules/Ap
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
describe('AppInitializationLogger', () => { describe('AppInitializationLogger', () => {
it('logs the app initialization marker upon bootstrap', () => { it('logs the app initialization marker upon bootstrap', async () => {
// arrange // arrange
const marker = '[APP_INIT]'; const marker = '[APP_INIT]';
const loggerStub = new LoggerStub(); const loggerStub = new LoggerStub();
const sut = new AppInitializationLogger(loggerStub); const sut = new AppInitializationLogger(loggerStub);
// act // act
sut.bootstrap(); await sut.bootstrap();
// assert // assert
expect(loggerStub.callHistory).to.have.lengthOf(1); expect(loggerStub.callHistory).to.have.lengthOf(1);
expect(loggerStub.callHistory[0].args).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 { describe, it, expect } from 'vitest';
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions'; import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
import { RuntimeSanityValidator } from '@/presentation/bootstrapping/Modules/RuntimeSanityValidator'; import { RuntimeSanityValidator } from '@/presentation/bootstrapping/Modules/RuntimeSanityValidator';
import { expectDoesNotThrowAsync, expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
describe('RuntimeSanityValidator', () => { describe('RuntimeSanityValidator', () => {
it('calls validator with correct options upon bootstrap', () => { it('calls validator with correct options upon bootstrap', async () => {
// arrange // arrange
const expectedOptions: ISanityCheckOptions = { const expectedOptions: ISanityCheckOptions = {
validateEnvironmentVariables: true, validateEnvironmentVariables: true,
@@ -15,11 +16,11 @@ describe('RuntimeSanityValidator', () => {
}; };
const sut = new RuntimeSanityValidator(validatorMock); const sut = new RuntimeSanityValidator(validatorMock);
// act // act
sut.bootstrap(); await sut.bootstrap();
// assert // assert
expect(actualOptions).to.deep.equal(expectedOptions); expect(actualOptions).to.deep.equal(expectedOptions);
}); });
it('propagates the error if validator fails', () => { it('propagates the error if validator fails', async () => {
// arrange // arrange
const expectedMessage = 'message thrown from validator'; const expectedMessage = 'message thrown from validator';
const validatorMock = () => { const validatorMock = () => {
@@ -27,17 +28,17 @@ describe('RuntimeSanityValidator', () => {
}; };
const sut = new RuntimeSanityValidator(validatorMock); const sut = new RuntimeSanityValidator(validatorMock);
// act // act
const act = () => sut.bootstrap(); const act = async () => { await sut.bootstrap(); };
// assert // assert
expect(act).to.throw(expectedMessage); await expectThrowsAsync(act, expectedMessage);
}); });
it('runs successfully if validator passes', () => { it('runs successfully if validator passes', async () => {
// arrange // arrange
const validatorMock = () => { /* NOOP */ }; const validatorMock = () => { /* NOOP */ };
const sut = new RuntimeSanityValidator(validatorMock); const sut = new RuntimeSanityValidator(validatorMock);
// act // act
const act = () => sut.bootstrap(); const act = async () => { await sut.bootstrap(); };
// assert // assert
expect(act).to.not.throw(); await expectDoesNotThrowAsync(act);
}); });
}); });

View File

@@ -5,11 +5,11 @@ const expectedAttributeName = 'data-interaction-does-not-collapse';
describe('NonCollapsingDirective', () => { describe('NonCollapsingDirective', () => {
describe('NonCollapsing', () => { describe('NonCollapsing', () => {
it('adds expected attribute to the element when inserted', () => { it('adds expected attribute to the element when mounted', () => {
// arrange // arrange
const element = createElementMock(); const element = createElementMock();
// act // act
NonCollapsing.inserted(element, undefined, undefined, undefined); NonCollapsing.mounted(element, undefined, undefined, undefined);
// assert // assert
expect(element.hasAttribute(expectedAttributeName)); expect(element.hasAttribute(expectedAttributeName));
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Wrapper, shallowMount } from '@vue/test-utils'; import { VueWrapper, shallowMount } from '@vue/test-utils';
import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue'; import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue'; import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue'; import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
@@ -10,10 +10,10 @@ import { InjectionKeys } from '@/presentation/injectionSymbols';
import { UseApplicationStub } from '@tests/unit/shared/Stubs/UseApplicationStub'; import { UseApplicationStub } from '@tests/unit/shared/Stubs/UseApplicationStub';
import { UserFilterMethod, UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub'; import { UserFilterMethod, UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub'; import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails'; import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub'; import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
import { FilterChangeDetailsStub } from '@tests/unit/shared/Stubs/FilterChangeDetailsStub';
const DOM_SELECTOR_NO_MATCHES = '.search-no-matches'; const DOM_SELECTOR_NO_MATCHES = '.search-no-matches';
const DOM_SELECTOR_CLOSE_BUTTON = '.search__query__close-button'; const DOM_SELECTOR_CLOSE_BUTTON = '.search__query__close-button';
@@ -123,7 +123,7 @@ describe('TheScriptsView.vue', () => {
new FilterResultStub().withQueryAndSomeMatches(), new FilterResultStub().withQueryAndSomeMatches(),
), ),
changeEvents: [ changeEvents: [
FilterChange.forClear(), FilterChangeDetailsStub.forClear(),
], ],
expectedComponent: CardList, expectedComponent: CardList,
componentsToDisappear: [ScriptsTree], componentsToDisappear: [ScriptsTree],
@@ -132,7 +132,7 @@ describe('TheScriptsView.vue', () => {
name: 'tree on search', name: 'tree on search',
initialView: ViewType.Cards, initialView: ViewType.Cards,
changeEvents: [ changeEvents: [
FilterChange.forApply( FilterChangeDetailsStub.forApply(
new FilterResultStub().withQueryAndSomeMatches(), new FilterResultStub().withQueryAndSomeMatches(),
), ),
], ],
@@ -143,10 +143,10 @@ describe('TheScriptsView.vue', () => {
name: 'return to card after search', name: 'return to card after search',
initialView: ViewType.Cards, initialView: ViewType.Cards,
changeEvents: [ changeEvents: [
FilterChange.forApply( FilterChangeDetailsStub.forApply(
new FilterResultStub().withQueryAndSomeMatches(), new FilterResultStub().withQueryAndSomeMatches(),
), ),
FilterChange.forClear(), FilterChangeDetailsStub.forClear(),
], ],
expectedComponent: CardList, expectedComponent: CardList,
componentsToDisappear: [ScriptsTree], componentsToDisappear: [ScriptsTree],
@@ -155,10 +155,10 @@ describe('TheScriptsView.vue', () => {
name: 'return to tree after search', name: 'return to tree after search',
initialView: ViewType.Tree, initialView: ViewType.Tree,
changeEvents: [ changeEvents: [
FilterChange.forApply( FilterChangeDetailsStub.forApply(
new FilterResultStub().withQueryAndSomeMatches(), new FilterResultStub().withQueryAndSomeMatches(),
), ),
FilterChange.forClear(), FilterChangeDetailsStub.forClear(),
], ],
expectedComponent: ScriptsTree, expectedComponent: ScriptsTree,
componentsToDisappear: [CardList], componentsToDisappear: [CardList],
@@ -223,11 +223,11 @@ describe('TheScriptsView.vue', () => {
}); });
// act // act
filterStub.notifyFilterChange(FilterChange.forApply( filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
new FilterResultStub().withQueryAndSomeMatches(), new FilterResultStub().withQueryAndSomeMatches(),
)); ));
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
filterStub.notifyFilterChange(FilterChange.forClear()); filterStub.notifyFilterChange(FilterChangeDetailsStub.forClear());
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// assert // assert
@@ -264,7 +264,7 @@ describe('TheScriptsView.vue', () => {
}); });
// act // act
filterStub.notifyFilterChange(FilterChange.forApply( filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
new FilterResultStub().withQueryAndSomeMatches(), new FilterResultStub().withQueryAndSomeMatches(),
)); ));
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
@@ -283,7 +283,7 @@ describe('TheScriptsView.vue', () => {
const wrapper = mountComponent({ const wrapper = mountComponent({
useCollectionState: stateStub.get(), useCollectionState: stateStub.get(),
}); });
filterStub.notifyFilterChange(FilterChange.forApply( filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
new FilterResultStub().withQueryAndSomeMatches(), new FilterResultStub().withQueryAndSomeMatches(),
)); ));
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
@@ -359,7 +359,7 @@ describe('TheScriptsView.vue', () => {
}); });
// act // act
filterStub.notifyFilterChange(FilterChange.forApply( filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
filter, filter,
)); ));
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
@@ -379,10 +379,10 @@ describe('TheScriptsView.vue', () => {
}); });
// act // act
filter.notifyFilterChange(FilterChange.forApply( filter.notifyFilterChange(FilterChangeDetailsStub.forApply(
new FilterResultStub().withSomeMatches(), new FilterResultStub().withSomeMatches(),
)); ));
filter.notifyFilterChange(FilterChange.forClear()); filter.notifyFilterChange(FilterChangeDetailsStub.forClear());
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// expect // expect
@@ -392,7 +392,7 @@ describe('TheScriptsView.vue', () => {
}); });
}); });
function expectComponentsToNotExist(wrapper: Wrapper<Vue>, components: readonly unknown[]) { function expectComponentsToNotExist(wrapper: VueWrapper, components: readonly unknown[]) {
const existingUnexpectedComponents = components const existingUnexpectedComponents = components
.map((component) => wrapper.findComponent(component)) .map((component) => wrapper.findComponent(component))
.filter((component) => component.exists()); .filter((component) => component.exists());
@@ -404,6 +404,7 @@ function mountComponent(options?: {
readonly viewType?: ViewType, readonly viewType?: ViewType,
}) { }) {
return shallowMount(TheScriptsView, { return shallowMount(TheScriptsView, {
global: {
provide: { provide: {
[InjectionKeys.useCollectionState as symbol]: [InjectionKeys.useCollectionState as symbol]:
() => options?.useCollectionState ?? new UseCollectionStateStub().get(), () => options?.useCollectionState ?? new UseCollectionStateStub().get(),
@@ -412,7 +413,8 @@ function mountComponent(options?: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]: [InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(), () => new UseAutoUnsubscribedEventsStub().get(),
}, },
propsData: { },
props: {
currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType, currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType,
}, },
}); });

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
Wrapper, shallowMount, VueWrapper, shallowMount,
mount, mount,
} from '@vue/test-utils'; } from '@vue/test-utils';
import { nextTick, defineComponent } from 'vue'; import { nextTick, defineComponent } from 'vue';
@@ -92,11 +92,11 @@ describe('ToggleSwitch.vue', () => {
const { checkboxWrapper } = getCheckboxElement(wrapper); const { checkboxWrapper } = getCheckboxElement(wrapper);
// act // act
await checkboxWrapper.setChecked(newCheckValue); await checkboxWrapper.setValue(newCheckValue);
await nextTick(); await nextTick();
// assert // assert
expect(wrapper.emitted().input).to.deep.equal([[newCheckValue]]); expect(wrapper.emitted('update:modelValue')).to.deep.equal([[newCheckValue]]);
}); });
}); });
}); });
@@ -122,11 +122,11 @@ describe('ToggleSwitch.vue', () => {
const { checkboxWrapper } = getCheckboxElement(wrapper); const { checkboxWrapper } = getCheckboxElement(wrapper);
// act // act
await checkboxWrapper.setChecked(value); await checkboxWrapper.setValue(value);
await nextTick(); await nextTick();
// assert // assert
expect(wrapper.emitted().input).to.equal(undefined); expect(wrapper.emitted('update:modelValue')).to.deep.equal(undefined);
}); });
}); });
}); });
@@ -145,7 +145,6 @@ describe('ToggleSwitch.vue', () => {
await nextTick(); await nextTick();
// assert // assert
expect(switchWrapper.exists());
const receivedEvents = parentWrapper.emitted(parentClickEventName); const receivedEvents = parentWrapper.emitted(parentClickEventName);
expect(receivedEvents).to.equal(undefined); expect(receivedEvents).to.equal(undefined);
}); });
@@ -161,14 +160,13 @@ describe('ToggleSwitch.vue', () => {
await nextTick(); await nextTick();
// assert // assert
expect(switchWrapper.exists());
const receivedEvents = parentWrapper.emitted(parentClickEventName); const receivedEvents = parentWrapper.emitted(parentClickEventName);
expect(receivedEvents).to.have.lengthOf(1); expect(receivedEvents).to.have.lengthOf(1);
}); });
}); });
}); });
function getCheckboxElement(wrapper: Wrapper<Vue>) { function getCheckboxElement(wrapper: VueWrapper) {
const checkboxWrapper = wrapper.find(DOM_INPUT_TOGGLE_CHECKBOX_SELECTOR); const checkboxWrapper = wrapper.find(DOM_INPUT_TOGGLE_CHECKBOX_SELECTOR);
const checkboxElement = checkboxWrapper.element as HTMLInputElement; const checkboxElement = checkboxWrapper.element as HTMLInputElement;
return { return {
@@ -184,9 +182,9 @@ function mountComponent(options?: {
readonly stopClickPropagation?: boolean, readonly stopClickPropagation?: boolean,
} }
}) { }) {
const wrapper = shallowMount(ToggleSwitch as unknown, { const wrapper = shallowMount(ToggleSwitch, {
propsData: { props: {
value: options?.properties?.modelValue, modelValue: options?.properties?.modelValue,
label: options?.properties?.label ?? 'test-label', label: options?.properties?.label ?? 'test-label',
stopClickPropagation: options?.properties?.stopClickPropagation, stopClickPropagation: options?.properties?.stopClickPropagation,
}, },
@@ -225,10 +223,12 @@ function mountToggleSwitchParent(options?: {
}, },
}); });
const wrapper = mount( const wrapper = mount(
parentComponent as unknown, parentComponent,
{ {
global: {
stubs: { ToggleSwitch: false }, stubs: { ToggleSwitch: false },
}, },
},
); );
return { return {
wrapper, wrapper,

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate, TreeViewFilterAction, TreeViewFilterPredicate,
createFilterRemovedEvent, createFilterTriggeredEvent, createFilterRemovedEvent, createFilterTriggeredEvent,
} from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent'; } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent';
@@ -47,19 +47,6 @@ describe('TreeViewFilterEvent', () => {
// expect // expect
expect(event.predicate).to.equal(predicate); expect(event.predicate).to.equal(predicate);
}); });
it('returns unique timestamp', () => {
// arrange
const instances = new Array<TreeViewFilterEvent>();
// act
instances.push(
createFilterTriggeredEvent(createPredicateStub()),
createFilterTriggeredEvent(createPredicateStub()),
createFilterTriggeredEvent(createPredicateStub()),
);
// assert
const uniqueDates = new Set(instances.map((instance) => instance.timestamp));
expect(uniqueDates).to.have.length(instances.length);
});
}); });
describe('createFilterRemovedEvent', () => { describe('createFilterRemovedEvent', () => {
@@ -79,19 +66,6 @@ describe('TreeViewFilterEvent', () => {
// assert // assert
expect(event.predicate).to.equal(expected); expect(event.predicate).to.equal(expected);
}); });
it('returns unique timestamp', () => {
// arrange
const instances = new Array<TreeViewFilterEvent>();
// act
instances.push(
createFilterRemovedEvent(),
createFilterRemovedEvent(),
createFilterRemovedEvent(),
);
// assert
const uniqueDates = new Set(instances.map((instance) => instance.timestamp));
expect(uniqueDates).to.have.length(instances.length);
});
}); });
}); });

View File

@@ -64,7 +64,7 @@ describe('useKeyboardInteractionState', () => {
const { listeners, windowStub } = createWindowStub(); const { listeners, windowStub } = createWindowStub();
// act // act
const { wrapper } = mountWrapperComponent(windowStub); const { wrapper } = mountWrapperComponent(windowStub);
wrapper.destroy(); wrapper.unmount();
await nextTick(); await nextTick();
// assert // assert
expect(listeners.keydown).to.have.lengthOf(0); expect(listeners.keydown).to.have.lengthOf(0);

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
ref, defineComponent, WatchSource, nextTick, shallowRef, defineComponent, WatchSource, nextTick,
} from 'vue'; } from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
@@ -16,7 +16,7 @@ describe('useNodeState', () => {
it('should set state on immediate invocation if node exists', () => { it('should set state on immediate invocation if node exists', () => {
// arrange // arrange
const expectedState = new TreeNodeStateDescriptorStub(); const expectedState = new TreeNodeStateDescriptorStub();
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined); const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
nodeWatcher.value = new TreeNodeStub() nodeWatcher.value = new TreeNodeStub()
.withState(new TreeNodeStateAccessStub().withCurrent(expectedState)); .withState(new TreeNodeStateAccessStub().withCurrent(expectedState));
// act // act
@@ -27,7 +27,7 @@ describe('useNodeState', () => {
it('should not set state on immediate invocation if node is undefined', () => { it('should not set state on immediate invocation if node is undefined', () => {
// arrange // arrange
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined); const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
// act // act
const { returnObject } = mountWrapperComponent(nodeWatcher); const { returnObject } = mountWrapperComponent(nodeWatcher);
// assert // assert
@@ -37,7 +37,7 @@ describe('useNodeState', () => {
it('should update state when nodeWatcher changes', async () => { it('should update state when nodeWatcher changes', async () => {
// arrange // arrange
const expectedNewState = new TreeNodeStateDescriptorStub(); const expectedNewState = new TreeNodeStateDescriptorStub();
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined); const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const { returnObject } = mountWrapperComponent(nodeWatcher); const { returnObject } = mountWrapperComponent(nodeWatcher);
// act // act
nodeWatcher.value = new TreeNodeStub() nodeWatcher.value = new TreeNodeStub()
@@ -49,7 +49,7 @@ describe('useNodeState', () => {
it('should update state when node state changes', () => { it('should update state when node state changes', () => {
// arrange // arrange
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined); const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const stateAccessStub = new TreeNodeStateAccessStub(); const stateAccessStub = new TreeNodeStateAccessStub();
const expectedChangedState = new TreeNodeStateDescriptorStub(); const expectedChangedState = new TreeNodeStateDescriptorStub();
nodeWatcher.value = new TreeNodeStub() nodeWatcher.value = new TreeNodeStub()
@@ -77,11 +77,13 @@ function mountWrapperComponent(nodeWatcher: WatchSource<ReadOnlyTreeNode | undef
template: '<div></div>', template: '<div></div>',
}), }),
{ {
global: {
provide: { provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]: [InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(), () => new UseAutoUnsubscribedEventsStub().get(),
}, },
}, },
},
); );
return { return {
wrapper, wrapper,

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
ref, defineComponent, WatchSource, nextTick, shallowRef, defineComponent, WatchSource, nextTick,
} from 'vue'; } from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot'; import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
@@ -15,7 +15,7 @@ describe('useCurrentTreeNodes', () => {
it('should set nodes on immediate invocation', () => { it('should set nodes on immediate invocation', () => {
// arrange // arrange
const expectedNodes = new QueryableNodesStub(); const expectedNodes = new QueryableNodesStub();
const treeWatcher = ref<TreeRoot>(new TreeRootStub().withCollection( const treeWatcher = shallowRef<TreeRoot>(new TreeRootStub().withCollection(
new TreeNodeCollectionStub().withNodes(expectedNodes), new TreeNodeCollectionStub().withNodes(expectedNodes),
)); ));
// act // act
@@ -27,7 +27,7 @@ describe('useCurrentTreeNodes', () => {
it('should update nodes when treeWatcher changes', async () => { it('should update nodes when treeWatcher changes', async () => {
// arrange // arrange
const initialNodes = new QueryableNodesStub(); const initialNodes = new QueryableNodesStub();
const treeWatcher = ref( const treeWatcher = shallowRef(
new TreeRootStub().withCollection(new TreeNodeCollectionStub().withNodes(initialNodes)), new TreeRootStub().withCollection(new TreeNodeCollectionStub().withNodes(initialNodes)),
); );
const { returnObject } = mountWrapperComponent(treeWatcher); const { returnObject } = mountWrapperComponent(treeWatcher);
@@ -45,7 +45,7 @@ describe('useCurrentTreeNodes', () => {
// arrange // arrange
const initialNodes = new QueryableNodesStub(); const initialNodes = new QueryableNodesStub();
const treeCollectionStub = new TreeNodeCollectionStub().withNodes(initialNodes); const treeCollectionStub = new TreeNodeCollectionStub().withNodes(initialNodes);
const treeWatcher = ref(new TreeRootStub().withCollection(treeCollectionStub)); const treeWatcher = shallowRef(new TreeRootStub().withCollection(treeCollectionStub));
const { returnObject } = mountWrapperComponent(treeWatcher); const { returnObject } = mountWrapperComponent(treeWatcher);
@@ -68,11 +68,13 @@ function mountWrapperComponent(treeWatcher: WatchSource<TreeRoot | undefined>) {
template: '<div></div>', template: '<div></div>',
}), }),
{ {
global: {
provide: { provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]: [InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(), () => new UseAutoUnsubscribedEventsStub().get(),
}, },
}, },
},
); );
return { return {
wrapper, wrapper,

View File

@@ -334,11 +334,13 @@ class UseNodeStateChangeAggregatorBuilder {
template: '<div></div>', template: '<div></div>',
}), }),
{ {
global: {
provide: { provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]: [InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => this.events.get(), () => this.events.get(),
}, },
}, },
},
); );
return { return {
wrapper, wrapper,

View File

@@ -170,9 +170,11 @@ function mountWrapperComponent() {
}, },
template: '<div></div>', template: '<div></div>',
}, { }, {
global: {
provide: { provide: {
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(), [InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
}, },
},
}); });
return { return {

View File

@@ -60,12 +60,14 @@ function mountWrapperComponent(scenario?: {
}, },
template: '<div></div>', template: '<div></div>',
}, { }, {
global: {
provide: { provide: {
[InjectionKeys.useCollectionState as symbol]: [InjectionKeys.useCollectionState as symbol]:
() => useStateStub.get(), () => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]: [InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(), () => new UseAutoUnsubscribedEventsStub().get(),
}, },
},
}); });
return { return {

View File

@@ -0,0 +1,281 @@
import { shallowMount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import { Ref, nextTick, watch } from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
import { useTreeViewFilterEvent } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseTreeViewFilterEvent';
import { FilterResultStub } from '@tests/unit/shared/Stubs/FilterResultStub';
import { TreeViewFilterAction, TreeViewFilterEvent } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent';
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
import { FilterChangeDetailsStub } from '@tests/unit/shared/Stubs/FilterChangeDetailsStub';
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
import { NodeMetadataStub } from '@tests/unit/shared/Stubs/NodeMetadataStub';
describe('UseTreeViewFilterEvent', () => {
describe('initially', () => {
testFilterEvents((filterChange) => {
// arrange
const useCollectionStateStub = new UseCollectionStateStub()
.withFilterResult(filterChange.filter);
// act
const { returnObject } = mountWrapperComponent({
useStateStub: useCollectionStateStub,
});
// assert
return Promise.resolve({
event: returnObject.latestFilterEvent,
});
});
});
describe('on filter state changed', () => {
describe('handles new event correctly', () => {
testFilterEvents((filterChange) => {
// arrange
const newFilter = filterChange;
const initialFilter = new FilterResultStub().withSomeMatches();
const filterStub = new UserFilterStub()
.withCurrentFilterResult(initialFilter);
const stateStub = new UseCollectionStateStub()
.withFilter(filterStub);
const { returnObject } = mountWrapperComponent({ useStateStub: stateStub });
// act
filterStub.notifyFilterChange(newFilter);
// assert
return Promise.resolve({
event: returnObject.latestFilterEvent,
});
});
});
describe('handles if event is fired multiple times with same object', () => {
testFilterEvents(async (filterChange) => {
// arrange
const newFilter = filterChange;
const initialFilter = new FilterResultStub().withSomeMatches();
const filterStub = new UserFilterStub()
.withCurrentFilterResult(initialFilter);
const stateStub = new UseCollectionStateStub()
.withFilter(filterStub);
const { returnObject } = mountWrapperComponent({ useStateStub: stateStub });
let totalFilterUpdates = 0;
watch(() => returnObject.latestFilterEvent.value, () => {
totalFilterUpdates++;
});
// act
filterStub.notifyFilterChange(newFilter);
await nextTick();
filterStub.notifyFilterChange(newFilter);
await nextTick();
// assert
expect(totalFilterUpdates).to.equal(2);
return {
event: returnObject.latestFilterEvent,
};
});
});
});
describe('on collection state changed', () => {
describe('sets initial filter from new collection state', () => {
testFilterEvents((filterChange) => {
// arrange
const newCollection = new CategoryCollectionStateStub()
.withFilter(new UserFilterStub().withCurrentFilterResult(filterChange.filter));
const initialCollection = new CategoryCollectionStateStub();
const useCollectionStateStub = new UseCollectionStateStub()
.withState(initialCollection);
// act
const { returnObject } = mountWrapperComponent({
useStateStub: useCollectionStateStub,
});
useCollectionStateStub.triggerOnStateChange({
newState: newCollection,
immediateOnly: false,
});
// assert
return Promise.resolve({
event: returnObject.latestFilterEvent,
});
});
});
describe('updates filter from new collection state', () => {
testFilterEvents((filterChange) => {
// arrange
const newFilter = filterChange;
const initialFilter = new FilterResultStub().withSomeMatches();
const filterStub = new UserFilterStub();
const newCollection = new CategoryCollectionStateStub()
.withFilter(filterStub.withCurrentFilterResult(initialFilter));
const initialCollection = new CategoryCollectionStateStub();
const useCollectionStateStub = new UseCollectionStateStub()
.withState(initialCollection);
// act
const { returnObject } = mountWrapperComponent({
useStateStub: useCollectionStateStub,
});
useCollectionStateStub.triggerOnStateChange({
newState: newCollection,
immediateOnly: false,
});
filterStub.notifyFilterChange(newFilter);
// assert
return Promise.resolve({
event: returnObject.latestFilterEvent,
});
});
});
});
});
function mountWrapperComponent(options?: {
readonly useStateStub?: UseCollectionStateStub,
}) {
const useStateStub = options.useStateStub ?? new UseCollectionStateStub();
let returnObject: ReturnType<typeof useTreeViewFilterEvent> | undefined;
shallowMount({
setup() {
returnObject = useTreeViewFilterEvent();
},
template: '<div></div>',
}, {
global: {
provide: {
[InjectionKeys.useCollectionState as symbol]:
() => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
},
});
return {
returnObject,
useStateStub,
};
}
type FilterChangeTestScenario = (result: IFilterChangeDetails) => Promise<{
readonly event: Ref<TreeViewFilterEvent>,
}>;
function testFilterEvents(
act: FilterChangeTestScenario,
) {
describe('handles cleared filter correctly', () => {
itExpectedFilterRemovedEvent(act);
});
describe('handles applied filter correctly', () => {
itExpectedFilterTriggeredEvent(act);
});
}
function itExpectedFilterRemovedEvent(
act: FilterChangeTestScenario,
) {
it('given cleared filter', async () => {
// arrange
const newFilter = FilterChangeDetailsStub.forClear();
// act
const { event } = await act(newFilter);
// assert
expectFilterEventAction(event, TreeViewFilterAction.Removed);
expect(event.value.predicate).toBeUndefined();
});
}
function itExpectedFilterTriggeredEvent(
act: FilterChangeTestScenario,
) {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly scriptMatches: IScript[],
readonly categoryMatches: ICategory[],
readonly givenNode: TreeNode,
readonly expectedPredicateResult: boolean;
}> = [
{
description: 'returns true when category exists',
scriptMatches: [],
categoryMatches: [new CategoryStub(1)],
givenNode: createNode({ id: '1', hasParent: false }),
expectedPredicateResult: true,
},
{
description: 'returns true when script exists',
scriptMatches: [new ScriptStub('a')],
categoryMatches: [],
givenNode: createNode({ id: 'a', hasParent: true }),
expectedPredicateResult: true,
},
{
description: 'returns false when category is missing',
scriptMatches: [new ScriptStub('b')],
categoryMatches: [new CategoryStub(2)],
givenNode: createNode({ id: '1', hasParent: false }),
expectedPredicateResult: false,
},
{
description: 'finds false when script is missing',
scriptMatches: [new ScriptStub('b')],
categoryMatches: [new CategoryStub(1)],
givenNode: createNode({ id: 'a', hasParent: true }),
expectedPredicateResult: false,
},
];
testScenarios.forEach(({
description, scriptMatches, categoryMatches, givenNode, expectedPredicateResult,
}) => {
it(description, async () => {
// arrange
const filterResult = new FilterResultStub()
.withScriptMatches(scriptMatches)
.withCategoryMatches(categoryMatches);
const filterChange = FilterChangeDetailsStub.forApply(filterResult);
// act
const { event } = await act(filterChange);
// assert
expectFilterEventAction(event, TreeViewFilterAction.Triggered);
expect(event.value.predicate).toBeDefined();
const actualPredicateResult = event.value.predicate(givenNode);
expect(actualPredicateResult).to.equal(
expectedPredicateResult,
[
'\n---',
`Script matches (${scriptMatches.length}): [${scriptMatches.map((s) => s.id).join(', ')}]`,
`Category matches (${categoryMatches.length}): [${categoryMatches.map((s) => s.id).join(', ')}]`,
`Expected node: "${givenNode.id}"`,
'---\n\n',
].join('\n'),
);
});
});
}
function createNode(options: {
readonly id: string;
readonly hasParent: boolean;
}): TreeNode {
return new TreeNodeStub()
.withId(options.id)
.withMetadata(new NodeMetadataStub().withId(options.id))
.withHierarchy(options.hasParent
? new HierarchyAccessStub().withParent(new TreeNodeStub())
: new HierarchyAccessStub());
}
function expectFilterEventAction(
event: Ref<TreeViewFilterEvent | undefined>,
expectedAction: TreeViewFilterAction,
) {
expect(event).toBeDefined();
expect(event.value).toBeDefined();
expect(event.value.action).to.equal(expectedAction);
}

View File

@@ -104,9 +104,11 @@ function mountWrapperComponent(categoryIdWatcher: WatchSource<number | undefined
}, },
template: '<div></div>', template: '<div></div>',
}, { }, {
global: {
provide: { provide: {
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(), [InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
}, },
},
}); });
return { return {

View File

@@ -44,7 +44,7 @@ describe('UseAutoUnsubscribedEvents', () => {
}); });
}); });
describe('event unsubscription', () => { describe('event unsubscription', () => {
it('unsubscribes from all events when the associated component is destroyed', () => { it('unsubscribes from all events when the associated component is unmounted', () => {
// arrange // arrange
const events = new EventSubscriptionCollectionStub(); const events = new EventSubscriptionCollectionStub();
const expectedCall: FunctionKeys<EventSubscriptionCollection> = 'unsubscribeAll'; const expectedCall: FunctionKeys<EventSubscriptionCollection> = 'unsubscribeAll';
@@ -58,7 +58,7 @@ describe('UseAutoUnsubscribedEvents', () => {
events.callHistory.length = 0; events.callHistory.length = 0;
// act // act
stubComponent.destroy(); stubComponent.unmount();
// assert // assert
expect(events.callHistory).to.have.lengthOf(1); expect(events.callHistory).to.have.lengthOf(1);

View File

@@ -62,12 +62,14 @@ function mountComponent(options: {
readonly loader: UseSvgLoaderStub, readonly loader: UseSvgLoaderStub,
}) { }) {
return shallowMount(AppIcon, { return shallowMount(AppIcon, {
propsData: { props: {
icon: options.iconPropValue, icon: options.iconPropValue,
}, },
global: {
provide: { provide: {
useSvgLoaderHook: options.loader.get(), useSvgLoaderHook: options.loader.get(),
}, },
},
}); });
} }

View File

@@ -56,7 +56,7 @@ describe('useEscapeKeyListener', () => {
// act // act
const wrapper = createComponent(); const wrapper = createComponent();
wrapper.destroy(); wrapper.unmount();
await nextTick(); await nextTick();
// assert // assert

View File

@@ -79,7 +79,7 @@ describe('useLockBodyBackgroundScroll', () => {
// act // act
const { component } = createComponent(true); const { component } = createComponent(true);
component.destroy(); component.unmount();
await nextTick(); await nextTick();
// assert // assert
@@ -92,7 +92,7 @@ describe('useLockBodyBackgroundScroll', () => {
const { component } = createComponent(true); const { component } = createComponent(true);
// act // act
component.destroy(); component.unmount();
await nextTick(); await nextTick();
// assert // assert

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import ModalContainer from '@/presentation/components/Shared/Modal/ModalContainer.vue'; import ModalContainer from '@/presentation/components/Shared/Modal/ModalContainer.vue';
const DOM_MODAL_CONTAINER_SELECTOR = '.modal-container'; const DOM_MODAL_CONTAINER_SELECTOR = '.modal-container';
@@ -32,18 +33,27 @@ describe('ModalContainer.vue', () => {
}); });
describe('modal open/close', () => { describe('modal open/close', () => {
it('opens when model prop changes from false to true', async () => { it('renders the model when prop changes from false to true', async () => {
// arrange // arrange
const wrapper = mountComponent({ modelValue: false }); const wrapper = mountComponent({ modelValue: false });
// act // act
await wrapper.setProps({ value: true }); await wrapper.setProps({ modelValue: true });
// assert after updating props // assert
// eslint-disable-next-line @typescript-eslint/no-explicit-any expect(wrapper.vm.isRendered).to.equal(true);
expect((wrapper.vm as any).isRendered).to.equal(true); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).isOpen).to.equal(true); it('opens the model when prop changes from false to true', async () => {
// arrange
const wrapper = mountComponent({ modelValue: false });
// act
await wrapper.setProps({ modelValue: true });
await nextTick();
// assert
expect(wrapper.vm.isOpen).to.equal(true);
}); });
it('closes when model prop changes from true to false', async () => { it('closes when model prop changes from true to false', async () => {
@@ -51,11 +61,10 @@ describe('ModalContainer.vue', () => {
const wrapper = mountComponent({ modelValue: true }); const wrapper = mountComponent({ modelValue: true });
// act // act
await wrapper.setProps({ value: false }); await wrapper.setProps({ modelValue: false });
// assert after updating props // assert
// eslint-disable-next-line @typescript-eslint/no-explicit-any expect(wrapper.vm.isOpen).to.equal(false);
expect((wrapper.vm as any).isOpen).to.equal(false);
// isRendered will not be true directly due to transition // isRendered will not be true directly due to transition
}); });
@@ -70,7 +79,7 @@ describe('ModalContainer.vue', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// assert // assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]); expect(wrapper.emitted('update:modelValue')).to.deep.equal([[false]]);
restore(); restore();
}); });
@@ -86,7 +95,7 @@ describe('ModalContainer.vue', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// assert // assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]); expect(wrapper.emitted('update:modelValue')).to.deep.equal([[false]]);
}); });
}); });
@@ -118,7 +127,7 @@ describe('ModalContainer.vue', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// assert // assert
expect(wrapper.emitted().input).to.equal(undefined); expect(wrapper.emitted('update:modelValue')).to.equal(undefined);
}); });
it('closes on overlay click if prop is true', async () => { it('closes on overlay click if prop is true', async () => {
@@ -131,7 +140,7 @@ describe('ModalContainer.vue', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// assert // assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]); expect(wrapper.emitted('update:modelValue')).to.deep.equal([[false]]);
}); });
}); });
}); });
@@ -142,14 +151,15 @@ function mountComponent(options: {
readonly slotHtml?: string, readonly slotHtml?: string,
readonly attachToDocument?: boolean, readonly attachToDocument?: boolean,
}) { }) {
return shallowMount(ModalContainer as unknown, { return shallowMount(ModalContainer, {
propsData: { props: {
value: options.modelValue, modelValue: options.modelValue,
...(options.closeOnOutsideClick !== undefined ? { ...(options.closeOnOutsideClick !== undefined ? {
closeOnOutsideClick: options.closeOnOutsideClick, closeOnOutsideClick: options.closeOnOutsideClick,
} : {}), } : {}),
}, },
slots: options.slotHtml !== undefined ? { default: options.slotHtml } : undefined, slots: options.slotHtml !== undefined ? { default: options.slotHtml } : undefined,
global: {
stubs: { stubs: {
[COMPONENT_MODAL_OVERLAY_NAME]: { [COMPONENT_MODAL_OVERLAY_NAME]: {
name: COMPONENT_MODAL_OVERLAY_NAME, name: COMPONENT_MODAL_OVERLAY_NAME,
@@ -160,6 +170,7 @@ function mountComponent(options: {
template: '<slot />', template: '<slot />',
}, },
}, },
},
}); });
} }

View File

@@ -96,8 +96,8 @@ function mountComponent(options?: {
readonly showProperty?: boolean, readonly showProperty?: boolean,
readonly slotHtml?: string, readonly slotHtml?: string,
}) { }) {
return shallowMount(ModalContent as unknown, { return shallowMount(ModalContent, {
propsData: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined, props: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined,
slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined, slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined,
}); });
} }

View File

@@ -23,7 +23,7 @@ describe('ModalDialog.vue', () => {
// assert // assert
const modalContainerWrapper = wrapper.findComponent(ModalContainer); const modalContainerWrapper = wrapper.findComponent(ModalContainer);
expect(modalContainerWrapper.props('value')).to.equal(true); expect(modalContainerWrapper.props('modelValue')).to.equal(true);
}); });
it('given false', () => { it('given false', () => {
// arrange & act // arrange & act
@@ -31,7 +31,7 @@ describe('ModalDialog.vue', () => {
// assert // assert
const modalContainerWrapper = wrapper.findComponent(ModalContainer); const modalContainerWrapper = wrapper.findComponent(ModalContainer);
expect(modalContainerWrapper.props('value')).to.equal(false); expect(modalContainerWrapper.props('modelValue')).to.equal(false);
}); });
}); });
@@ -57,7 +57,7 @@ describe('ModalDialog.vue', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
// assert // assert
expect(wrapper.emitted().input[0]).to.deep.equal([false]); expect(wrapper.emitted('update:modelValue')).to.deep.equal([[false]]);
}); });
}); });
}); });
@@ -68,15 +68,17 @@ function mountComponent(options?: {
readonly deepMount?: boolean, readonly deepMount?: boolean,
}) { }) {
const mountFunction = options?.deepMount === true ? mount : shallowMount; const mountFunction = options?.deepMount === true ? mount : shallowMount;
const wrapper = mountFunction(ModalDialog as unknown, { const wrapper = mountFunction(ModalDialog, {
propsData: options?.modelValue !== undefined ? { value: options?.modelValue } : undefined, props: options?.modelValue !== undefined ? { modelValue: options?.modelValue } : undefined,
slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined, slots: options?.slotHtml !== undefined ? { default: options?.slotHtml } : undefined,
global: {
stubs: options?.deepMount === true ? undefined : { stubs: options?.deepMount === true ? undefined : {
[MODAL_CONTAINER_COMPONENT_NAME]: { [MODAL_CONTAINER_COMPONENT_NAME]: {
name: MODAL_CONTAINER_COMPONENT_NAME, name: MODAL_CONTAINER_COMPONENT_NAME,
template: '<slot />', template: '<slot />',
}, },
}, },
},
}); });
return wrapper; return wrapper;
} }

View File

@@ -99,7 +99,7 @@ describe('ModalOverlay.vue', () => {
}); });
function mountComponent(options?: { readonly showProperty?: boolean }) { function mountComponent(options?: { readonly showProperty?: boolean }) {
return shallowMount(ModalOverlay as unknown, { return shallowMount(ModalOverlay, {
propsData: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined, props: options?.showProperty !== undefined ? { show: options?.showProperty } : undefined,
}); });
} }

View File

@@ -16,3 +16,15 @@ export async function expectThrowsAsync(
expect(error.message).to.equal(errorMessage); expect(error.message).to.equal(errorMessage);
} }
} }
export async function expectDoesNotThrowAsync(
method: () => Promise<unknown>,
) {
let error: Error | undefined;
try {
await method();
} catch (err) {
error = err;
}
expect(error).toBeUndefined();
}

View File

@@ -0,0 +1,14 @@
import { App } from 'vue';
import { Bootstrapper } from '@/presentation/bootstrapping/Bootstrapper';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
export class BootstrapperStub
extends StubWithObservableMethodCalls<Bootstrapper>
implements Bootstrapper {
async bootstrap(app: App): Promise<void> {
this.registerMethodCall({
methodName: 'bootstrap',
args: [app],
});
}
}

View File

@@ -0,0 +1,26 @@
import { FilterActionType } from '@/application/Context/State/Filter/Event/FilterActionType';
import { IFilterChangeDetails, IFilterChangeDetailsVisitor } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
export class FilterChangeDetailsStub implements IFilterChangeDetails {
public static forApply(filter: IFilterResult) {
return new FilterChangeDetailsStub(FilterActionType.Apply, filter);
}
public static forClear() {
return new FilterChangeDetailsStub(FilterActionType.Clear);
}
private constructor(
public actionType: FilterActionType,
public filter?: IFilterResult,
) { /* Private constructor to enforce factory methods */ }
visit(visitor: IFilterChangeDetailsVisitor): void {
if (this.filter) {
visitor.onApply(this.filter);
} else {
visitor.onClear();
}
}
}

View File

@@ -1,4 +1,4 @@
import { ref } from 'vue'; import { shallowRef } from 'vue';
import { import {
ContextModifier, IStateCallbackSettings, NewStateEventHandler, ContextModifier, IStateCallbackSettings, NewStateEventHandler,
StateModifier, useCollectionState, StateModifier, useCollectionState,
@@ -8,7 +8,6 @@ import { IUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IApplicationContext } from '@/application/Context/IApplicationContext'; import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { CategoryCollectionStateStub } from './CategoryCollectionStateStub'; import { CategoryCollectionStateStub } from './CategoryCollectionStateStub';
import { EventSubscriptionCollectionStub } from './EventSubscriptionCollectionStub';
import { ApplicationContextStub } from './ApplicationContextStub'; import { ApplicationContextStub } from './ApplicationContextStub';
import { UserFilterStub } from './UserFilterStub'; import { UserFilterStub } from './UserFilterStub';
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
@@ -17,7 +16,9 @@ export class UseCollectionStateStub
extends StubWithObservableMethodCalls<ReturnType<typeof useCollectionState>> { extends StubWithObservableMethodCalls<ReturnType<typeof useCollectionState>> {
private currentContext: IApplicationContext = new ApplicationContextStub(); private currentContext: IApplicationContext = new ApplicationContextStub();
private readonly currentState = ref<ICategoryCollectionState>(new CategoryCollectionStateStub()); private readonly currentState = shallowRef<ICategoryCollectionState>(
new CategoryCollectionStateStub(),
);
public withFilter(filter: IUserFilter) { public withFilter(filter: IUserFilter) {
const state = new CategoryCollectionStateStub() const state = new CategoryCollectionStateStub()
@@ -100,7 +101,6 @@ export class UseCollectionStateStub
onStateChange: this.onStateChange.bind(this), onStateChange: this.onStateChange.bind(this),
currentContext: this.currentContext, currentContext: this.currentContext,
currentState: this.currentState, currentState: this.currentState,
events: new EventSubscriptionCollectionStub(),
}; };
} }
} }

View File

@@ -1,5 +1,5 @@
import { import {
WatchSource, readonly, shallowRef, triggerRef, WatchSource, shallowReadonly, shallowRef, triggerRef,
} from 'vue'; } from 'vue';
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot'; import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes'; import { useCurrentTreeNodes } from '@/presentation/components/Scripts/View/Tree/TreeView/UseCurrentTreeNodes';
@@ -25,7 +25,7 @@ export class UseCurrentTreeNodesStub {
return (treeWatcher: WatchSource<TreeRoot>) => { return (treeWatcher: WatchSource<TreeRoot>) => {
this.treeWatcher = treeWatcher; this.treeWatcher = treeWatcher;
return { return {
nodes: readonly(this.nodes), nodes: shallowReadonly(this.nodes),
}; };
}; };
} }

View File

@@ -1,8 +1,8 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { resolve } from 'path'; import { resolve } from 'path';
import { defineConfig, UserConfig } from 'vite'; import { defineConfig, UserConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import legacy from '@vitejs/plugin-legacy'; import legacy from '@vitejs/plugin-legacy';
import vue from '@vitejs/plugin-vue2';
import ViteYaml from '@modyfi/vite-plugin-yaml'; import ViteYaml from '@modyfi/vite-plugin-yaml';
import distDirs from './dist-dirs.json' assert { type: 'json' }; import distDirs from './dist-dirs.json' assert { type: 'json' };
import { getAliasesFromTsConfig, getClientEnvironmentVariables, getSelfDirectoryAbsolutePath } from './vite-config-helper'; import { getAliasesFromTsConfig, getClientEnvironmentVariables, getSelfDirectoryAbsolutePath } from './vite-config-helper';