Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7219f6e1 | ||
|
|
8ccaec7af6 | ||
|
|
b2ffc90da7 | ||
|
|
72e4d0b896 | ||
|
|
5bb13e34f8 | ||
|
|
0466b86f10 | ||
|
|
ca81f68ff1 | ||
|
|
4995e49c46 | ||
|
|
77123d8c92 | ||
|
|
e72c1c13ea | ||
|
|
e775d68a9b | ||
|
|
f8e5f1a5a2 | ||
|
|
f4a74f058d | ||
|
|
80821fca07 | ||
|
|
dfd4451561 | ||
|
|
8570b02dde | ||
|
|
d6da406c61 | ||
|
|
060e789662 | ||
|
|
e40b9a3cf5 | ||
|
|
237d9944f9 |
2
.github/workflows/release.site.yaml
vendored
2
.github/workflows/release.site.yaml
vendored
@@ -102,7 +102,7 @@ jobs:
|
||||
-
|
||||
name: "App: Deploy to S3"
|
||||
shell: bash
|
||||
run: >-
|
||||
run: |-
|
||||
declare web_output_dir
|
||||
if ! web_output_dir=$(cd app && node scripts/print-dist-dir.js --web); then
|
||||
echo 'Error: Could not determine distribution directory.'
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.6 (2023-11-03)
|
||||
|
||||
* Bump dependencies to latest | [25d7f7b](https://github.com/undergroundwires/privacy.sexy/commit/25d7f7b2a479e51e092881cc2751e67a7d3f179f)
|
||||
* win: improve system app uninstall cleanup #73 | [dbe3c5c](https://github.com/undergroundwires/privacy.sexy/commit/dbe3c5cfb91ba8a1657838b69117858843c8fbc8)
|
||||
* win: improve system app uninstall /w fallback #260 | [98a26f9](https://github.com/undergroundwires/privacy.sexy/commit/98a26f9ae47af2668aa53f39d1768983036048ce)
|
||||
* Improve performance of rendering during search | [79b46bf](https://github.com/undergroundwires/privacy.sexy/commit/79b46bf21004d96d31551439e5db5d698a3f71f3)
|
||||
* Fix YAML error for site release in CI/CD | [237d994](https://github.com/undergroundwires/privacy.sexy/commit/237d9944f900f5172366868d75219224ff0542b0)
|
||||
* win: fix Microsoft Advertising app removal #200 | [e40b9a3](https://github.com/undergroundwires/privacy.sexy/commit/e40b9a3cf53c341f2e84023a9f0e9680ac08f3fa)
|
||||
* win: improve directory cleanup security | [060e789](https://github.com/undergroundwires/privacy.sexy/commit/060e7896624309aebd25e8b190c127282de177e8)
|
||||
* Centralize Electron entry file path configuration | [d6da406](https://github.com/undergroundwires/privacy.sexy/commit/d6da406c61e5b9f5408851d1302d6d7398157a2e)
|
||||
* win: prevent updates from reinstalling apps #260 | [8570b02](https://github.com/undergroundwires/privacy.sexy/commit/8570b02dde14ffad64863f614682c3fc1f87b6c2)
|
||||
* win: improve script environment robustness #221 | [dfd4451](https://github.com/undergroundwires/privacy.sexy/commit/dfd44515613f38abe5a806bda36f44e7b715b50b)
|
||||
* Fix compiler failing with nested `with` expression | [80821fc](https://github.com/undergroundwires/privacy.sexy/commit/80821fca0769e5fd2c6338918fbdcea12fbe83d2)
|
||||
* win: improve soft file/app delete security #260 | [f4a74f0](https://github.com/undergroundwires/privacy.sexy/commit/f4a74f058db9b5bcbcbe438785db5ec88ecc1657)
|
||||
* Fix incorrect tooltip position after window resize | [f8e5f1a](https://github.com/undergroundwires/privacy.sexy/commit/f8e5f1a5a2afa1f18567e6d965359b6a1f082367)
|
||||
* linux: fix string formatting of Firefox configs | [e775d68](https://github.com/undergroundwires/privacy.sexy/commit/e775d68a9b4a5f9e893ff0e3500dade036185193)
|
||||
* win: improve file delete | [e72c1c1](https://github.com/undergroundwires/privacy.sexy/commit/e72c1c13ea2d73ebfc7a8da5a21254fdfc0e5b59)
|
||||
* win: change system app removal to hard delete #260 | [77123d8](https://github.com/undergroundwires/privacy.sexy/commit/77123d8c929d23676a9cb21d7b697703fd1b6e82)
|
||||
* Improve UI performance by optimizing reactivity | [4995e49](https://github.com/undergroundwires/privacy.sexy/commit/4995e49c469211404dac9fcb79b75eb121f80bce)
|
||||
* Migrate to Vue 3.0 #230 | [ca81f68](https://github.com/undergroundwires/privacy.sexy/commit/ca81f68ff1c3bbe5b22981096ae9220b0b5851c7)
|
||||
* win, linux: unify & improve Firefox clean-up #273 | [0466b86](https://github.com/undergroundwires/privacy.sexy/commit/0466b86f1013341c966a9bbf6513990337b16598)
|
||||
* win: fix store revert for multiple installs #260 | [5bb13e3](https://github.com/undergroundwires/privacy.sexy/commit/5bb13e34f8de2e2a7ba943ff72b12c0569435e62)
|
||||
|
||||
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.5...0.12.6)
|
||||
|
||||
## 0.12.5 (2023-10-13)
|
||||
|
||||
* Fix Docker build and improve checks #220 | [7669985](https://github.com/undergroundwires/privacy.sexy/commit/7669985f8e1446e726a95626ecf35b3ce6b60a16)
|
||||
|
||||
@@ -43,6 +43,7 @@ You have two alternatives:
|
||||
|
||||
1. [Create an issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose) and ask for someone else to add the script for you.
|
||||
2. Or send a PR yourself. This would make it faster to get your code into the project. You need to add scripts to related OS in [collections](src/application/collections/) folder. Then you'd sent a pull request, see [pull request process](#pull-request-process).
|
||||
- 💡 You should use existing shared functions for most of the operations, like `DisableService` for disabling services, to maintain code consistency and efficiency.
|
||||
- 📖 If you're unsure about the syntax, check [collection-files.md](docs/collection-files.md).
|
||||
- 📖 If you wish to use templates, use [templating.md](./docs/templating.md).
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -122,7 +122,7 @@
|
||||
## Get started
|
||||
|
||||
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
|
||||
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-Setup-0.12.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.AppImage). For more options, see [here](#additional-install-options).
|
||||
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-Setup-0.12.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.6/privacy.sexy-0.12.6.AppImage). For more options, see [here](#additional-install-options).
|
||||
|
||||
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
|
||||
|
||||
@@ -153,12 +153,21 @@ Online version does not require to run any software on your computer. Offline ve
|
||||
## Additional Install Options
|
||||
|
||||
- Check the [releases page](https://github.com/undergroundwires/privacy.sexy/releases) for all available versions.
|
||||
- Using [Scoop](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) package manager on Windows:
|
||||
- Other unofficial channels (not maintained by privacy.sexy) for Windows include:
|
||||
- [Scoop 🥄](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) (latest version):
|
||||
|
||||
```powershell
|
||||
scoop bucket add extras
|
||||
scoop install privacy.sexy
|
||||
```
|
||||
```powershell
|
||||
scoop bucket add extras
|
||||
scoop install privacy.sexy
|
||||
```
|
||||
|
||||
- [winget 🪟](https://winget.run/pkg/undergroundwires/privacy.sexy) (may be outdated):
|
||||
|
||||
```powershell
|
||||
winget install -e --id undergroundwires.privacy.sexy
|
||||
```
|
||||
|
||||
With winget, updates require manual submission; the auto-update feature within privacy.sexy will notify you of new releases post-installation.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -2,79 +2,142 @@
|
||||
|
||||
## Benefits of templating
|
||||
|
||||
- Generating scripts by sharing code to increase best-practice usage and maintainability.
|
||||
- Creating self-contained scripts without cross-dependencies.
|
||||
- Use of pipes for writing cleaner code and letting pipes do dirty work.
|
||||
- **Code sharing:** Share code across scripts for consistent practices and easier maintenance.
|
||||
- **Script independence:** Generate self-contained scripts, eliminating the need for external code.
|
||||
- **Cleaner code:** Use pipes for complex operations, resulting in more readable and streamlined code.
|
||||
|
||||
## Expressions
|
||||
|
||||
- Expressions start and end with mustaches (double brackets, `{{` and `}}`).
|
||||
- E.g. `Hello {{ $name }} !`
|
||||
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) but not the same.
|
||||
- Functions enables usage of expressions.
|
||||
- In script definition parts of a function, see [`Function`](./collection-files.md#Function).
|
||||
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
|
||||
- Expressions inside expressions (nested templates) are supported.
|
||||
- An expression can output another expression that will also be compiled.
|
||||
- E.g. following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output.
|
||||
**Syntax:**
|
||||
|
||||
```go
|
||||
{{ with $condition }}
|
||||
echo {{ $text }}
|
||||
{{ end }}
|
||||
```
|
||||
Expressions are enclosed within `{{` and `}}`.
|
||||
Example: `Hello {{ $name }}!`.
|
||||
They are a core component of templating, enhancing scripts with dynamic capabilities and functionality.
|
||||
|
||||
**Syntax similarity:**
|
||||
|
||||
The syntax shares similarities with [Go Templates ❤️](https://pkg.go.dev/text/template), but with some differences:
|
||||
|
||||
**Function definitions:**
|
||||
|
||||
You can use expressions in function definition.
|
||||
Refer to [Function](./collection-files.md#function) for more details.
|
||||
|
||||
Example usage:
|
||||
|
||||
```yaml
|
||||
name: GreetFunction
|
||||
parameters:
|
||||
- name: name
|
||||
code: Hello {{ $name }}!
|
||||
```
|
||||
|
||||
If you assign `name` the value `world`, invoking `GreetFunction` would result in `Hello world!`.
|
||||
|
||||
**Function arguments:**
|
||||
|
||||
You can also use expressions in arguments in nested function calls.
|
||||
Refer to [`Function | collection-files.md`](./collection-files.md#functioncall) for more details.
|
||||
|
||||
Example with nested function calls:
|
||||
|
||||
```yaml
|
||||
-
|
||||
name: PrintMessageFunction
|
||||
parameters:
|
||||
- name: message
|
||||
code: echo "{{ $message }}"
|
||||
-
|
||||
name: GreetUserFunction
|
||||
parameters:
|
||||
- name: userName
|
||||
call:
|
||||
name: PrintMessageFunction
|
||||
parameters:
|
||||
argument: 'Hello, {{ $userName }}!'
|
||||
```
|
||||
|
||||
Here, if `userName` is `Alice`, invoking `GreetUserFunction` would execute `echo "Hello, Alice!"`.
|
||||
|
||||
**Nested templates:**
|
||||
|
||||
You can nest expressions inside expressions (also called "nested templates").
|
||||
This means that an expression can output another expression where compiler will compile both.
|
||||
|
||||
For example, following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output:
|
||||
|
||||
```go
|
||||
{{ with $condition }}
|
||||
echo {{ $text }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Parameter substitution
|
||||
|
||||
A simple function example:
|
||||
Parameter substitution dynamically replaces variable references with their corresponding values in the script.
|
||||
|
||||
**Example function:**
|
||||
|
||||
```yaml
|
||||
function: EchoArgument
|
||||
name: DisplayTextFunction
|
||||
parameters:
|
||||
- name: 'argument'
|
||||
code: Hello {{ $argument }} !
|
||||
- name: 'text'
|
||||
code: echo {{ $text }}
|
||||
```
|
||||
|
||||
It would print "Hello world" if it's called in a [script](./collection-files.md#script) as following:
|
||||
|
||||
```yaml
|
||||
script: Echo script
|
||||
call:
|
||||
function: EchoArgument
|
||||
parameters:
|
||||
argument: World
|
||||
```
|
||||
|
||||
A function can call other functions such as:
|
||||
|
||||
```yaml
|
||||
-
|
||||
function: CallerFunction
|
||||
parameters:
|
||||
- name: 'value'
|
||||
call:
|
||||
function: EchoArgument
|
||||
parameters:
|
||||
argument: {{ $value }}
|
||||
-
|
||||
function: EchoArgument
|
||||
parameters:
|
||||
- name: 'argument'
|
||||
code: Hello {{ $argument }} !
|
||||
```
|
||||
Invoking `DisplayTextFunction` with `text` set to `"Hello, world!"` would result in `echo "Hello, World!"`.
|
||||
|
||||
### with
|
||||
|
||||
Skips its "block" if the variable is absent or empty. Its "block" is between `with` start (`{{ with .. }}`) and end (`{{ end }`}) expressions.
|
||||
E.g. `{{ with $parameterName }} Hi, I'm a block! {{ end }}` would only output `Hi, I'm a block!` if `parameterName` has any value..
|
||||
The `with` expression enables conditional rendering and provides a context variable for simpler code.
|
||||
|
||||
It binds its context (value of the provided parameter value) as arbitrary `.` value. It allows you to use the argument value of the given parameter when it is provided and not empty such as:
|
||||
**Optional block rendering:**
|
||||
|
||||
If the provided variable is falsy (`false`, `null`, or empty), the compiler skips the enclosed block of code.
|
||||
A "block" lies between the with start (`{{ with .. }}`) and end (`{{ end }}`) expressions, defining its boundaries.
|
||||
|
||||
Example:
|
||||
|
||||
```go
|
||||
{{ with $optionalVariable }}
|
||||
Hello
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
This would display `Hello` if `$optionalVariable` is truthy.
|
||||
|
||||
**Parameter declaration:**
|
||||
|
||||
You should set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
|
||||
|
||||
Declare parameters used for `with` condition as optional such as:
|
||||
|
||||
```yaml
|
||||
name: ConditionalOutputFunction
|
||||
parameters:
|
||||
- name: 'data'
|
||||
optional: true
|
||||
code: |-
|
||||
{{ with $data }}
|
||||
Data is: {{ . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
**Context variable:**
|
||||
|
||||
`with` statement binds its context (value of the provided parameter value) as arbitrary `.` value.
|
||||
`{{ . }}` syntax gives you access to the context variable.
|
||||
This is optional to use, and not required to use `with` expressions.
|
||||
|
||||
For example:
|
||||
|
||||
```go
|
||||
{{ with $parameterName }}Parameter value is {{ . }} here {{ end }}
|
||||
```
|
||||
|
||||
It supports multiline text inside the block. You can have something like:
|
||||
**Multiline text:**
|
||||
|
||||
It supports multiline text inside the block. You can write something like:
|
||||
|
||||
```go
|
||||
{{ with $argument }}
|
||||
@@ -83,7 +146,9 @@ It supports multiline text inside the block. You can have something like:
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution):
|
||||
**Inner expressions:**
|
||||
|
||||
You can also embed other expressions inside its block, such as [parameter substitution](#parameter-substitution):
|
||||
|
||||
```go
|
||||
{{ with $condition }}
|
||||
@@ -91,32 +156,44 @@ You can also use other expressions inside its block, such as [parameter substitu
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
|
||||
This also includes nesting `with` statements:
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
function: FunctionThatOutputsConditionally
|
||||
parameters:
|
||||
- name: 'argument'
|
||||
optional: true
|
||||
code: |-
|
||||
{{ with $argument }}
|
||||
Value is: {{ . }}
|
||||
```go
|
||||
{{ with $condition1 }}
|
||||
Value of $condition1: {{ . }}
|
||||
{{ with $condition2 }}
|
||||
Value of $condition2: {{ . }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### Pipes
|
||||
|
||||
- Pipes are functions available for handling text.
|
||||
- Allows stacking actions one after another also known as "chaining".
|
||||
- Like [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), the concept is simple: each pipeline's output becomes the input of the following pipe.
|
||||
- You cannot create pipes. [A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
|
||||
- You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
|
||||
- ❗ Pipe names must be camelCase without any space or special characters.
|
||||
- **Existing pipes**
|
||||
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
|
||||
- `escapeDoubleQuotes`: Escapes `"` characters, allows you to use them inside double quotes (`"`).
|
||||
- **Example usages**
|
||||
- `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}`
|
||||
- `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}`
|
||||
Pipes are functions designed for text manipulation.
|
||||
They allow for a sequential application of operations resembling [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), also known as "chaining".
|
||||
Each pipeline's output becomes the input of the following pipe.
|
||||
|
||||
**Pre-defined**:
|
||||
|
||||
Pipes are pre-defined by the system.
|
||||
You cannot create pipes in [collection files](./collection-files.md).
|
||||
[A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
|
||||
|
||||
**Compatibility:**
|
||||
|
||||
You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
|
||||
|
||||
For example:
|
||||
|
||||
```go
|
||||
{{ with $script }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}
|
||||
```
|
||||
|
||||
**Naming:**
|
||||
|
||||
❗ Pipe names must be camelCase without any space or special characters.
|
||||
|
||||
**Available pipes:**
|
||||
|
||||
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
|
||||
- `escapeDoubleQuotes`: Escapes `"` characters for batch command execution, allows you to use them inside double quotes (`"`).
|
||||
|
||||
@@ -8,15 +8,21 @@ import distDirs from './dist-dirs.json' assert { type: 'json' };
|
||||
const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts');
|
||||
const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts');
|
||||
const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html');
|
||||
const DIST_DIR = resolvePathFromProjectRoot(distDirs.electronUnbundled);
|
||||
const ELECTRON_DIST_SUBDIRECTORIES = {
|
||||
main: resolveElectronDistSubdirectory('main'),
|
||||
preload: resolveElectronDistSubdirectory('preload'),
|
||||
renderer: resolveElectronDistSubdirectory('renderer'),
|
||||
};
|
||||
|
||||
process.env.ELECTRON_ENTRY = resolve(ELECTRON_DIST_SUBDIRECTORIES.main, 'index.cjs');
|
||||
|
||||
export default defineConfig({
|
||||
main: getSharedElectronConfig({
|
||||
distDirSubfolder: 'main',
|
||||
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.main,
|
||||
entryFilePath: MAIN_ENTRY_FILE,
|
||||
}),
|
||||
preload: getSharedElectronConfig({
|
||||
distDirSubfolder: 'preload',
|
||||
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.preload,
|
||||
entryFilePath: PRELOAD_ENTRY_FILE,
|
||||
}),
|
||||
renderer: mergeConfig(
|
||||
@@ -25,7 +31,7 @@ export default defineConfig({
|
||||
}),
|
||||
{
|
||||
build: {
|
||||
outDir: resolve(DIST_DIR, 'renderer'),
|
||||
outDir: ELECTRON_DIST_SUBDIRECTORIES.renderer,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: WEB_INDEX_HTML_PATH,
|
||||
@@ -42,7 +48,7 @@ function getSharedElectronConfig(options: {
|
||||
}): UserConfig {
|
||||
return {
|
||||
build: {
|
||||
outDir: resolve(DIST_DIR, options.distDirSubfolder),
|
||||
outDir: options.distDirSubfolder,
|
||||
lib: {
|
||||
entry: options.entryFilePath,
|
||||
},
|
||||
@@ -64,6 +70,11 @@ function getSharedElectronConfig(options: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePathFromProjectRoot(pathSegment: string) {
|
||||
function resolvePathFromProjectRoot(pathSegment: string): string {
|
||||
return resolve(__dirname, pathSegment);
|
||||
}
|
||||
|
||||
function resolveElectronDistSubdirectory(subDirectory: string): string {
|
||||
const electronDistDir = resolvePathFromProjectRoot(distDirs.electronUnbundled);
|
||||
return resolve(electronDistDir, subDirectory);
|
||||
}
|
||||
|
||||
1183
package-lock.json
generated
1183
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "privacy.sexy",
|
||||
"version": "0.12.5",
|
||||
"version": "0.12.6",
|
||||
"private": true,
|
||||
"slogan": "Now you have the choice",
|
||||
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
|
||||
"author": "undergroundwires",
|
||||
"type": "module",
|
||||
"main": "./dist-electron-unbundled/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
@@ -43,7 +42,7 @@
|
||||
"electron-updater": "^6.1.4",
|
||||
"file-saver": "^2.0.5",
|
||||
"markdown-it": "^13.0.2",
|
||||
"vue": "^2.7.14"
|
||||
"vue": "^3.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||
@@ -53,17 +52,17 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@vitejs/plugin-legacy": "^4.1.1",
|
||||
"@vitejs/plugin-vue2": "^2.2.0",
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"@vue/test-utils": "^1.3.6",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cypress": "^13.3.1",
|
||||
"electron": "^27.0.0",
|
||||
"electron-builder": "^24.6.4",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-vite": "^1.0.27",
|
||||
"electron-vite": "^1.0.28",
|
||||
"eslint": "^8.51.0",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
@@ -98,5 +97,11 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/undergroundwires/privacy.sexy.git"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"dmg-license": "^1.0.11"
|
||||
},
|
||||
"//optionalDependencies": {
|
||||
"dmg-license": "Required by `electron-builder` for DMG builds on macOS, https://github.com/electron-userland/electron-builder/issues/6489, https://github.com/electron-userland/electron-builder/issues/6520"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,45 +14,44 @@ export class ExpressionRegexBuilder {
|
||||
.addRawRegex('\\s+');
|
||||
}
|
||||
|
||||
public matchPipeline() {
|
||||
public captureOptionalPipeline() {
|
||||
return this
|
||||
.expectZeroOrMoreWhitespaces()
|
||||
.addRawRegex('(\\|\\s*.+?)?');
|
||||
.addRawRegex('((?:\\|\\s*\\b[a-zA-Z]+\\b\\s*)*)');
|
||||
}
|
||||
|
||||
public matchUntilFirstWhitespace() {
|
||||
public captureUntilWhitespaceOrPipe() {
|
||||
return this
|
||||
.addRawRegex('([^|\\s]+)');
|
||||
}
|
||||
|
||||
public matchMultilineAnythingExceptSurroundingWhitespaces() {
|
||||
public captureMultilineAnythingExceptSurroundingWhitespaces() {
|
||||
return this
|
||||
.expectZeroOrMoreWhitespaces()
|
||||
.addRawRegex('([\\S\\s]+?)')
|
||||
.expectZeroOrMoreWhitespaces();
|
||||
.expectOptionalWhitespaces()
|
||||
.addRawRegex('([\\s\\S]*\\S)')
|
||||
.expectOptionalWhitespaces();
|
||||
}
|
||||
|
||||
public expectExpressionStart() {
|
||||
return this
|
||||
.expectCharacters('{{')
|
||||
.expectZeroOrMoreWhitespaces();
|
||||
.expectOptionalWhitespaces();
|
||||
}
|
||||
|
||||
public expectExpressionEnd() {
|
||||
return this
|
||||
.expectZeroOrMoreWhitespaces()
|
||||
.expectOptionalWhitespaces()
|
||||
.expectCharacters('}}');
|
||||
}
|
||||
|
||||
public expectOptionalWhitespaces() {
|
||||
return this
|
||||
.addRawRegex('\\s*');
|
||||
}
|
||||
|
||||
public buildRegExp(): RegExp {
|
||||
return new RegExp(this.parts.join(''), 'g');
|
||||
}
|
||||
|
||||
private expectZeroOrMoreWhitespaces() {
|
||||
return this
|
||||
.addRawRegex('\\s*');
|
||||
}
|
||||
|
||||
private addRawRegex(regex: string) {
|
||||
this.parts.push(regex);
|
||||
return this;
|
||||
|
||||
@@ -6,8 +6,9 @@ export class ParameterSubstitutionParser extends RegexParser {
|
||||
protected readonly regex = new ExpressionRegexBuilder()
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('$')
|
||||
.matchUntilFirstWhitespace() // First match: Parameter name
|
||||
.matchPipeline() // Second match: Pipeline
|
||||
.captureUntilWhitespaceOrPipe() // First capture: Parameter name
|
||||
.expectOptionalWhitespaces()
|
||||
.captureOptionalPipeline() // Second capture: Pipeline
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
|
||||
@@ -1,59 +1,222 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
|
||||
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
|
||||
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
|
||||
import { IExpression } from '../Expression/IExpression';
|
||||
import { ExpressionPosition } from '../Expression/ExpressionPosition';
|
||||
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
|
||||
|
||||
export class WithParser extends RegexParser {
|
||||
protected readonly regex = new ExpressionRegexBuilder()
|
||||
// {{ with $parameterName }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('with')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('$')
|
||||
.matchUntilFirstWhitespace() // First match: parameter name
|
||||
.expectExpressionEnd()
|
||||
// ...
|
||||
.matchMultilineAnythingExceptSurroundingWhitespaces() // Second match: Scope text
|
||||
// {{ end }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('end')
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
export class WithParser implements IExpressionParser {
|
||||
public findExpressions(code: string): IExpression[] {
|
||||
if (!code) {
|
||||
throw new Error('missing code');
|
||||
}
|
||||
return parseWithExpressions(code);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
|
||||
const parameterName = match[1];
|
||||
const scopeText = match[2];
|
||||
enum WithStatementType {
|
||||
Start,
|
||||
End,
|
||||
ContextVariable,
|
||||
}
|
||||
|
||||
type WithStatement = {
|
||||
readonly type: WithStatementType.Start;
|
||||
readonly parameterName: string;
|
||||
readonly position: ExpressionPosition;
|
||||
} | {
|
||||
readonly type: WithStatementType.End;
|
||||
readonly position: ExpressionPosition;
|
||||
} | {
|
||||
readonly type: WithStatementType.ContextVariable;
|
||||
readonly position: ExpressionPosition;
|
||||
readonly pipeline: string | undefined;
|
||||
};
|
||||
|
||||
function parseAllWithExpressions(
|
||||
input: string,
|
||||
): WithStatement[] {
|
||||
const expressions = new Array<WithStatement>();
|
||||
for (const match of input.matchAll(WithStatementStartRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.Start,
|
||||
parameterName: match[1],
|
||||
position: createPosition(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(WithStatementEndRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.End,
|
||||
position: createPosition(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(ContextVariableWithPipelineRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.ContextVariable,
|
||||
position: createPosition(match),
|
||||
pipeline: match[1],
|
||||
});
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
function createPosition(match: RegExpMatchArray): ExpressionPosition {
|
||||
const startPos = match.index;
|
||||
const endPos = startPos + match[0].length;
|
||||
return new ExpressionPosition(startPos, endPos);
|
||||
}
|
||||
|
||||
class WithStatementBuilder {
|
||||
private readonly contextVariables = new Array<{
|
||||
readonly positionInScope: ExpressionPosition;
|
||||
readonly pipeline: string | undefined;
|
||||
}>();
|
||||
|
||||
public addContextVariable(
|
||||
absolutePosition: ExpressionPosition,
|
||||
pipeline: string | undefined,
|
||||
): void {
|
||||
const positionInScope = new ExpressionPosition(
|
||||
absolutePosition.start - this.startExpressionPosition.end,
|
||||
absolutePosition.end - this.startExpressionPosition.end,
|
||||
);
|
||||
this.contextVariables.push({
|
||||
positionInScope,
|
||||
pipeline,
|
||||
});
|
||||
}
|
||||
|
||||
public buildExpression(endExpressionPosition: ExpressionPosition, input: string): IExpression {
|
||||
const parameters = new FunctionParameterCollection();
|
||||
parameters.addParameter(new FunctionParameter(this.parameterName, true));
|
||||
const position = new ExpressionPosition(
|
||||
this.startExpressionPosition.start,
|
||||
endExpressionPosition.end,
|
||||
);
|
||||
const scope = input.substring(this.startExpressionPosition.end, endExpressionPosition.start);
|
||||
return {
|
||||
parameters: [new FunctionParameter(parameterName, true)],
|
||||
evaluator: (context) => {
|
||||
const argumentValue = context.args.hasArgument(parameterName)
|
||||
? context.args.getArgument(parameterName).argumentValue
|
||||
parameters,
|
||||
position,
|
||||
evaluate: (context) => {
|
||||
const argumentValue = context.args.hasArgument(this.parameterName)
|
||||
? context.args.getArgument(this.parameterName).argumentValue
|
||||
: undefined;
|
||||
if (!argumentValue) {
|
||||
return '';
|
||||
}
|
||||
return replaceEachScopeSubstitution(scopeText, (pipeline) => {
|
||||
const substitutedScope = this.substituteContextVariables(scope, (pipeline) => {
|
||||
if (!pipeline) {
|
||||
return argumentValue;
|
||||
}
|
||||
return context.pipelineCompiler.compile(argumentValue, pipeline);
|
||||
});
|
||||
return substitutedScope;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly startExpressionPosition: ExpressionPosition,
|
||||
private readonly parameterName: string,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
private substituteContextVariables(
|
||||
scope: string,
|
||||
substituter: (pipeline: string) => string,
|
||||
): string {
|
||||
if (!this.contextVariables.length) {
|
||||
return scope;
|
||||
}
|
||||
let substitutedScope = '';
|
||||
let scopeSubstrIndex = 0;
|
||||
for (const contextVariable of this.contextVariables) {
|
||||
substitutedScope += scope.substring(scopeSubstrIndex, contextVariable.positionInScope.start);
|
||||
substitutedScope += substituter(contextVariable.pipeline);
|
||||
scopeSubstrIndex = contextVariable.positionInScope.end;
|
||||
}
|
||||
substitutedScope += scope.substring(scopeSubstrIndex, scope.length);
|
||||
return substitutedScope;
|
||||
}
|
||||
}
|
||||
|
||||
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
|
||||
function buildErrorContext(code: string, statements: readonly WithStatement[]): string {
|
||||
const formattedStatements = statements.map((s) => `- [${s.position.start}, ${s.position.end}] ${WithStatementType[s.type]}`).join('\n');
|
||||
return [
|
||||
'Code:', '---', code, '---',
|
||||
'nStatements:', '---', formattedStatements, '---',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function parseWithExpressions(input: string): IExpression[] {
|
||||
const allStatements = parseAllWithExpressions(input);
|
||||
const sortedStatements = allStatements
|
||||
.slice()
|
||||
.sort((a, b) => b.position.start - a.position.start);
|
||||
const expressions = new Array<IExpression>();
|
||||
const builders = new Array<WithStatementBuilder>();
|
||||
const throwWithContext = (message: string) => {
|
||||
throw new Error(`${message}\n${buildErrorContext(input, allStatements)}}`);
|
||||
};
|
||||
while (sortedStatements.length > 0) {
|
||||
const statement = sortedStatements.pop();
|
||||
if (!statement) {
|
||||
break;
|
||||
}
|
||||
switch (statement.type) { // eslint-disable-line default-case
|
||||
case WithStatementType.Start:
|
||||
builders.push(new WithStatementBuilder(
|
||||
statement.position,
|
||||
statement.parameterName,
|
||||
));
|
||||
break;
|
||||
case WithStatementType.ContextVariable:
|
||||
if (builders.length === 0) {
|
||||
throwWithContext('Context variable before `with` statement.');
|
||||
}
|
||||
builders[builders.length - 1].addContextVariable(statement.position, statement.pipeline);
|
||||
break;
|
||||
case WithStatementType.End:
|
||||
if (builders.length === 0) {
|
||||
throwWithContext('Redundant `end` statement, missing `with`?');
|
||||
}
|
||||
expressions.push(builders.pop().buildExpression(statement.position, input));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (builders.length > 0) {
|
||||
throwWithContext('Missing `end` statement, forgot `{{ end }}?');
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
const ContextVariableWithPipelineRegEx = new ExpressionRegexBuilder()
|
||||
// {{ . | pipeName }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('.')
|
||||
.matchPipeline() // First match: pipeline
|
||||
.expectOptionalWhitespaces()
|
||||
.captureOptionalPipeline() // First capture: pipeline
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) {
|
||||
// Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets,
|
||||
// but instead letting the pipeline compiler to fail on those.
|
||||
return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => {
|
||||
return replacer(match1);
|
||||
});
|
||||
}
|
||||
const WithStatementStartRegEx = new ExpressionRegexBuilder()
|
||||
// {{ with $parameterName }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('with')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('$')
|
||||
.captureUntilWhitespaceOrPipe() // First capture: parameter name
|
||||
.expectExpressionEnd()
|
||||
.expectOptionalWhitespaces()
|
||||
.buildRegExp();
|
||||
|
||||
const WithStatementEndRegEx = new ExpressionRegexBuilder()
|
||||
// {{ end }}
|
||||
.expectOptionalWhitespaces()
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('end')
|
||||
.expectOptionalWhitespaces()
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
@@ -707,15 +707,9 @@ actions:
|
||||
-
|
||||
category: Clear Firefox history
|
||||
docs: |-
|
||||
Mozilla Firefox, or simply Firefox, is a free and open-source web browser developed by the Mozilla Foundation and
|
||||
its subsidiary the Mozilla Corporation [1].
|
||||
This category encompasses a series of scripts aimed at helping users manage and delete their browsing history and related data in Mozilla Firefox.
|
||||
|
||||
Firefox stores user-related data in user profiles [2].
|
||||
|
||||
See also [the Firefox homepage](https://web.archive.org/web/20221029214632/https://www.mozilla.org/en-US/firefox/).
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145113/https://en.wikipedia.org/wiki/Firefox "Firefox | Wikipedia | en.wikipedia.org"
|
||||
[2]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
The scripts are designed to target different aspects of user data stored by Firefox, providing users options for maintaining privacy and freeing up disk space.
|
||||
children:
|
||||
-
|
||||
name: Clear Firefox cache
|
||||
@@ -755,9 +749,13 @@ actions:
|
||||
# Snap installation
|
||||
rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/*
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: crashes/
|
||||
pathGlob: crashes/*
|
||||
-
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
pathGlob: crashes/events/*
|
||||
-
|
||||
name: Clear Firefox cookies
|
||||
docs: |-
|
||||
@@ -765,41 +763,37 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: cookies.sqlite
|
||||
pathGlob: cookies.sqlite
|
||||
-
|
||||
name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.)
|
||||
# This script (name, documentation and code) is same in Linux and Windows collections.
|
||||
# Changes should be done at both places.
|
||||
# Marked: refactor-with-partials
|
||||
docs: |-
|
||||
The file "places.sqlite" stores the annotations, bookmarks, favorite icons, input history, keywords, and browsing history (a record of visited pages) [1].
|
||||
The tables include [1]:
|
||||
- `moz_anno_attributes`: Annotation Attributes
|
||||
- `moz_annos`: Annotations
|
||||
- `moz_bookmarks`: Bookmarks
|
||||
- `moz_bookmarks_roots`: Bookmark roots i.e. places, menu, toolbar, tags, unfiled
|
||||
- `moz_favicons`: Favorite icons - including URL of icon
|
||||
- `moz_historyvisits`: A history of the number of times a site has been visited
|
||||
- `moz_inputhistory`: A history of URLs typed by the user
|
||||
- `moz_items_annos`: Item annotations
|
||||
- `moz_keywords`: Keywords
|
||||
- `moz_places`: Places/Sites visited - referenced by `moz_historyvisits`
|
||||
URL data is stored in the `moz_places` table. However, this table is connected to `moz_annos`, `moz_bookmarks`, and `moz_inputhistory` and `moz_historyvisits`.
|
||||
As these entries are connected to each other, we'll delete all of them at the same time [2].
|
||||
This script targets the Firefox browsing history, including URLs, downloads, bookmarks, and site visits, by deleting specific database entries.
|
||||
|
||||
**Bookmarks**:
|
||||
Firefox bookmarks are stored in tables such as `moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots` [3].
|
||||
There are also not very well documented tables, such as `moz_bookmarks_deleted` [4].
|
||||
Firefox stores various user data in a file named `places.sqlite`. This file includes:
|
||||
|
||||
**Downloads:**
|
||||
Firefox downloads are stored in the 'places.sqlite' database, within the 'moz_annos' table [5].
|
||||
The entries in `moz_annos` are linked to `moz_places` that store the actual history entry (`moz_places.id = moz_annos.place_id`) [6].
|
||||
Associated URL information is stored within the 'moz_places' table [5].
|
||||
Downloads have been historically stored in `downloads.rdf` for Firefox 2.x and below [7].
|
||||
Starting with Firefox 3.x they're stored in `downloads.sqlite` [7].
|
||||
- Annotations, bookmarks, and favorite icons (`moz_anno_attributes`, `moz_annos`, `moz_favicons`) [1]
|
||||
- Browsing history, a record of pages visited (`moz_places`, `moz_historyvisits`) [1]
|
||||
- Keywords and typed URLs (`moz_keywords`, `moz_inputhistory`) [1]
|
||||
- Item annotations (`moz_items_annos`) [1]
|
||||
- Bookmark roots such as places, menu, toolbar, tags, unfiled (`moz_bookmarks_roots`) [1]
|
||||
|
||||
**Favicons:**
|
||||
Firefox favicons are stored in the `favicons.sqlite` database, within the `moz_icons` table [5].
|
||||
Older versions of Firefox stored Favicons in the 'places.sqlite' database, within the `moz_favicons` table [5].
|
||||
The `moz_places` table holds URL data, connecting to various other tables like `moz_annos`, `moz_bookmarks`, `moz_inputhistory`, and `moz_historyvisits` [2].
|
||||
Due to these connections, the script removes entries from all relevant tables simultaneously to maintain database integrity.
|
||||
|
||||
**Bookmarks**: Stored across several tables (`moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots`) [3], with additional undocumented tables like `moz_bookmarks_deleted` [4].
|
||||
|
||||
**Downloads**: Stored in the 'places.sqlite' database, within the 'moz_annos' table [5]. The entries in `moz_annos` are linked to `moz_places` that store the actual history entry
|
||||
(`moz_places.id = moz_annos.place_id`) [6]. Associated URL information is stored within the 'moz_places' table [5]. Downloads have been historically stored in `downloads.rdf` for Firefox 2.x
|
||||
and below [7], and `downloads.sqlite` later on [7].
|
||||
|
||||
**Favicons**: Older Firefox versions stored favicons in `places.sqlite` within the `moz_favicons` table [5], while newer versions use `favicons.sqlite` and the `moz_icons` table [5].
|
||||
|
||||
By executing this script, users can ensure their Firefox browsing history, bookmarks, and downloads are thoroughly removed, contributing to a cleaner and more private browsing experience.
|
||||
|
||||
[1]: https://web.archive.org/web/20221029141626/https://kb.mozillazine.org/Places.sqlite "Places.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
|
||||
[2]: https://web.archive.org/web/20221030160803/https://wiki.mozilla.org/images/0/08/Places.sqlite.schema.pdf "Places.sqlite.schema.pdf | Mozilla Wiki"
|
||||
@@ -810,21 +804,21 @@ actions:
|
||||
[7]: https://web.archive.org/web/20221029145712/https://kb.mozillazine.org/Downloads.rdf "Downloads.rdf | MozillaZine Knowledge Base | kb.mozillazine.org"
|
||||
call:
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: downloads.rdf
|
||||
pathGlob: downloads.rdf
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: downloads.sqlite
|
||||
pathGlob: downloads.sqlite
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: places.sqlite
|
||||
pathGlob: places.sqlite
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: favicons.sqlite
|
||||
pathGlob: favicons.sqlite
|
||||
-
|
||||
name: Clear Firefox logins
|
||||
docs: |-
|
||||
@@ -837,17 +831,17 @@ actions:
|
||||
[2]: https://web.archive.org/web/20221029145757/https://bugzilla.mozilla.org/show_bug.cgi?id=1593467 "1593467 - Automatically restore from logins-backup.json when logins.json is missing or corrupt | Bugzilla | mozilla.org | bugzilla.mozilla.org"
|
||||
call:
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: logins.json
|
||||
pathGlob: logins.json
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: logins-backup.json
|
||||
pathGlob: logins-backup.json
|
||||
-
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: signons.sqlite
|
||||
pathGlob: signons.sqlite
|
||||
-
|
||||
name: Clear Firefox autocomplete history
|
||||
docs: |-
|
||||
@@ -856,9 +850,9 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: formhistory.sqlite
|
||||
pathGlob: formhistory.sqlite
|
||||
-
|
||||
name: Clear Firefox "Multi-Account Containers" data
|
||||
docs: |-
|
||||
@@ -866,9 +860,9 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: containers.json
|
||||
pathGlob: containers.json
|
||||
-
|
||||
name: Clear Firefox open tabs and windows data
|
||||
docs: |-
|
||||
@@ -878,9 +872,9 @@ actions:
|
||||
|
||||
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
|
||||
call:
|
||||
function: DeleteFromFirefoxProfiles
|
||||
function: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
path: sessionstore.jsonlz4
|
||||
pathGlob: sessionstore.jsonlz4
|
||||
-
|
||||
category: Clear system and kernel usage data
|
||||
docs: |-
|
||||
@@ -2911,7 +2905,8 @@ actions:
|
||||
function: AddFirefoxPrefs
|
||||
parameters:
|
||||
prefName: toolkit.telemetry.log.level
|
||||
jsonValue: 'Fatal'
|
||||
jsonValue: >-
|
||||
"Fatal"
|
||||
-
|
||||
name: Disable Firefox telemetry log output
|
||||
recommend: standard
|
||||
@@ -2924,7 +2919,8 @@ actions:
|
||||
function: AddFirefoxPrefs
|
||||
parameters:
|
||||
prefName: toolkit.telemetry.log.dump
|
||||
jsonValue: 'Fatal'
|
||||
jsonValue: >-
|
||||
"Fatal"
|
||||
-
|
||||
name: Clear Firefox telemetry user ID
|
||||
recommend: standard
|
||||
@@ -3491,16 +3487,66 @@ functions:
|
||||
>&2 echo "Failed, $service does not exist."
|
||||
fi
|
||||
-
|
||||
name: DeleteFromFirefoxProfiles
|
||||
name: Comment
|
||||
# 💡 Purpose:
|
||||
# Adds a comment in the executed code for better readability and debugging.
|
||||
# This function does not affect the execution flow but helps in understanding the purpose of subsequent code.
|
||||
parameters:
|
||||
- name: path # file or folder in profile file
|
||||
code: |-
|
||||
# {{ $path }}: Global installation
|
||||
rm -rfv ~/.mozilla/firefox/*/{{ $path }}
|
||||
# {{ $path }}: Flatpak installation
|
||||
rm -rfv ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $path }}
|
||||
# {{ $path }}: Snap installation
|
||||
rm -rfv ~/snap/firefox/common/.mozilla/firefox/*/{{ $path }}
|
||||
- name: codeComment
|
||||
optional: true
|
||||
- name: revertCodeComment
|
||||
optional: true
|
||||
call:
|
||||
function: RunInlineCode
|
||||
parameters:
|
||||
code: '{{ with $codeComment }}# {{ . }}{{ end }}'
|
||||
revertCode: '{{ with $revertCodeComment }}# {{ . }}{{ end }}'
|
||||
-
|
||||
name: DeleteFiles
|
||||
parameters:
|
||||
- name: fileGlob
|
||||
call:
|
||||
-
|
||||
function: Comment
|
||||
parameters:
|
||||
codeComment: >-
|
||||
Delete files matching pattern: "{{ $fileGlob }}"
|
||||
-
|
||||
function: RunPython3Code
|
||||
parameters:
|
||||
code: |-
|
||||
import glob
|
||||
import os
|
||||
path = '{{ $fileGlob }}'
|
||||
expanded_path = os.path.expandvars(os.path.expanduser(path))
|
||||
print(f'Deleting files matching pattern: {expanded_path}')
|
||||
paths = glob.glob(expanded_path)
|
||||
if not paths:
|
||||
print('Skipping, no paths found.')
|
||||
for path in paths:
|
||||
if not os.path.isfile(path):
|
||||
print(f'Skipping folder: "{path}".')
|
||||
continue
|
||||
os.remove(path)
|
||||
print(f'Successfully delete file: "{path}".')
|
||||
print(f'Successfully deleted {len(paths)} file(s).')
|
||||
-
|
||||
name: DeleteFilesFromFirefoxProfiles
|
||||
parameters:
|
||||
- name: pathGlob # file or folder in profile file
|
||||
call:
|
||||
- # Global installation
|
||||
function: DeleteFiles
|
||||
parameters:
|
||||
fileGlob: ~/.mozilla/firefox/*/{{ $pathGlob }}
|
||||
- # Flatpak installation
|
||||
function: DeleteFiles
|
||||
parameters:
|
||||
fileGlob: ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $pathGlob }}
|
||||
- # Snap installation
|
||||
function: DeleteFiles
|
||||
parameters:
|
||||
fileGlob: ~/snap/firefox/common/.mozilla/firefox/*/{{ $pathGlob }}
|
||||
-
|
||||
name: CleanTableFromFirefoxProfileDatabase
|
||||
parameters:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
export class Clipboard {
|
||||
public static copyText(text: string): void {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', ''); // to avoid focus
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,7 @@
|
||||
}
|
||||
|
||||
.#{$name}-leave-active,
|
||||
.#{$name}-enter, // Vue 2.X compatibility
|
||||
.#{$name}-enter-from // Vue 3.X compatibility
|
||||
.#{$name}-enter-from
|
||||
{
|
||||
opacity: 0;
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
|
||||
import { VueBootstrapper } from './Modules/VueBootstrapper';
|
||||
import { Bootstrapper } from './Bootstrapper';
|
||||
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
|
||||
import { AppInitializationLogger } from './Modules/AppInitializationLogger';
|
||||
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
|
||||
import type { App } from 'vue';
|
||||
|
||||
export class ApplicationBootstrapper implements IVueBootstrapper {
|
||||
public bootstrap(vue: VueConstructor): void {
|
||||
const bootstrappers = ApplicationBootstrapper.getAllBootstrappers();
|
||||
for (const bootstrapper of bootstrappers) {
|
||||
bootstrapper.bootstrap(vue);
|
||||
export class ApplicationBootstrapper implements Bootstrapper {
|
||||
constructor(private readonly bootstrappers = ApplicationBootstrapper.getAllBootstrappers()) { }
|
||||
|
||||
public async bootstrap(app: App): Promise<void> {
|
||||
for (const bootstrapper of this.bootstrappers) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await bootstrapper.bootstrap(app); // Not running `Promise.all` because order matters.
|
||||
}
|
||||
}
|
||||
|
||||
private static getAllBootstrappers(): IVueBootstrapper[] {
|
||||
private static getAllBootstrappers(): Bootstrapper[] {
|
||||
return [
|
||||
new VueBootstrapper(),
|
||||
new RuntimeSanityValidator(),
|
||||
new DependencyBootstrapper(),
|
||||
new AppInitializationLogger(),
|
||||
];
|
||||
}
|
||||
|
||||
5
src/presentation/bootstrapping/Bootstrapper.ts
Normal file
5
src/presentation/bootstrapping/Bootstrapper.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { App } from 'vue';
|
||||
|
||||
export interface Bootstrapper {
|
||||
bootstrap(app: App): Promise<void>;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook
|
||||
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { useClipboard } from '../components/Shared/Hooks/Clipboard/UseClipboard';
|
||||
import { useCurrentCode } from '../components/Shared/Hooks/UseCurrentCode';
|
||||
|
||||
export function provideDependencies(
|
||||
context: IApplicationContext,
|
||||
@@ -23,6 +25,12 @@ export function provideDependencies(
|
||||
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
return useCollectionState(context, events);
|
||||
});
|
||||
registerTransient(InjectionKeys.useClipboard, () => useClipboard());
|
||||
registerTransient(InjectionKeys.useCurrentCode, () => {
|
||||
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
const state = api.inject(InjectionKeys.useCollectionState)();
|
||||
return useCurrentCode(state, events);
|
||||
});
|
||||
}
|
||||
|
||||
export interface VueDependencyInjectionApi {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { VueConstructor } from 'vue';
|
||||
|
||||
export interface IVueBootstrapper {
|
||||
bootstrap(vue: VueConstructor): void;
|
||||
}
|
||||
|
||||
export { VueConstructor };
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ILogger } from '@/infrastructure/Log/ILogger';
|
||||
import { IVueBootstrapper } from '../IVueBootstrapper';
|
||||
import { Bootstrapper } from '../Bootstrapper';
|
||||
import { ClientLoggerFactory } from '../ClientLoggerFactory';
|
||||
|
||||
export class AppInitializationLogger implements IVueBootstrapper {
|
||||
export class AppInitializationLogger implements Bootstrapper {
|
||||
constructor(
|
||||
private readonly logger: ILogger = ClientLoggerFactory.Current.logger,
|
||||
) { }
|
||||
|
||||
public bootstrap(): void {
|
||||
public async bootstrap(): Promise<void> {
|
||||
// Do not remove [APP_INIT]; it's a marker used in tests.
|
||||
this.logger.info('[APP_INIT] Application is initialized.');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
|
||||
import { IVueBootstrapper } from '../IVueBootstrapper';
|
||||
import { Bootstrapper } from '../Bootstrapper';
|
||||
|
||||
export class RuntimeSanityValidator implements IVueBootstrapper {
|
||||
export class RuntimeSanityValidator implements Bootstrapper {
|
||||
constructor(private readonly validator = validateRuntimeSanity) {
|
||||
|
||||
}
|
||||
|
||||
public bootstrap(): void {
|
||||
public async bootstrap(): Promise<void> {
|
||||
this.validator({
|
||||
validateEnvironmentVariables: true,
|
||||
validateWindowVariables: true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,6 @@ import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
|
||||
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
|
||||
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
|
||||
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import { provideDependencies } from '../bootstrapping/DependencyProvider';
|
||||
|
||||
const singletonAppContext = await buildContext();
|
||||
|
||||
const OptionalDevToolkit = process.env.NODE_ENV !== 'production'
|
||||
? defineAsyncComponent(() => import('@/presentation/components/DevToolkit/DevToolkit.vue'))
|
||||
@@ -36,9 +32,7 @@ export default defineComponent({
|
||||
TheFooter,
|
||||
OptionalDevToolkit,
|
||||
},
|
||||
setup() {
|
||||
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
|
||||
},
|
||||
setup() { },
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<IconButton
|
||||
text="Copy"
|
||||
@click="copyCode"
|
||||
icon-name="copy"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import IconButton from './IconButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
},
|
||||
setup() {
|
||||
const { copyText } = inject(InjectionKeys.useClipboard)();
|
||||
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
|
||||
|
||||
async function copyCode() {
|
||||
await copyText(currentCode.value);
|
||||
}
|
||||
|
||||
return {
|
||||
copyCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<IconButton
|
||||
v-if="canRun"
|
||||
text="Run"
|
||||
@click="executeCode"
|
||||
icon-name="play"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, computed, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import IconButton from './IconButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
},
|
||||
setup() {
|
||||
const { currentState, currentContext } = inject(InjectionKeys.useCollectionState)();
|
||||
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||
|
||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
||||
|
||||
async function executeCode() {
|
||||
await runCode(currentContext);
|
||||
}
|
||||
|
||||
return {
|
||||
isDesktopVersion: isDesktop,
|
||||
canRun,
|
||||
executeCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function getCanRunState(
|
||||
selectedOs: OperatingSystem,
|
||||
isDesktopVersion: boolean,
|
||||
hostOs: OperatingSystem,
|
||||
): boolean {
|
||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||
return isDesktopVersion && isRunningOnSelectedOs;
|
||||
}
|
||||
|
||||
async function runCode(context: IReadOnlyApplicationContext) {
|
||||
const runner = new CodeRunner();
|
||||
await runner.runCode(
|
||||
/* code: */ context.state.code.current,
|
||||
/* appName: */ context.app.info.name,
|
||||
/* fileExtension: */ context.state.collection.scripting.fileExtension,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
@click="onClicked"
|
||||
>
|
||||
<AppIcon
|
||||
class="button__icon"
|
||||
:icon="iconName"
|
||||
/>
|
||||
<div class="button__text">{{text}}</div>
|
||||
</button>
|
||||
<div class="button-wrapper">
|
||||
<button
|
||||
class="button"
|
||||
type="button"
|
||||
@click="onClicked"
|
||||
>
|
||||
<AppIcon
|
||||
class="button__icon"
|
||||
:icon="iconName"
|
||||
/>
|
||||
<div class="button__text">{{text}}</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -49,10 +51,20 @@ export default defineComponent({
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
.button-wrapper {
|
||||
position: relative;
|
||||
height: 70px;
|
||||
.button {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background-color: $color-secondary;
|
||||
color: $color-on-secondary;
|
||||
@@ -70,19 +82,17 @@ export default defineComponent({
|
||||
|
||||
@include clickable;
|
||||
|
||||
width: 10%;
|
||||
min-width: 90px;
|
||||
@include hover-or-touch {
|
||||
background: $color-surface;
|
||||
box-shadow: 0px 2px 10px 5px $color-secondary;
|
||||
.button__text {
|
||||
display: block;
|
||||
}
|
||||
.button__icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@include hover-or-touch('>&__text') {
|
||||
display: block;
|
||||
}
|
||||
@include hover-or-touch('>&__icon') {
|
||||
display: none;
|
||||
}
|
||||
&__text {
|
||||
.button__text {
|
||||
display: none;
|
||||
font-family: $font-artistic;
|
||||
font-size: 1.5em;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<IconButton
|
||||
:text="isDesktopVersion ? 'Save' : 'Download'"
|
||||
@click="saveCode"
|
||||
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
|
||||
/>
|
||||
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
|
||||
<InstructionList :data="instructions" />
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, computed, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import IconButton from '../IconButton.vue';
|
||||
import InstructionList from './Instructions/InstructionList.vue';
|
||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
InstructionList,
|
||||
ModalDialog,
|
||||
},
|
||||
setup() {
|
||||
const { currentState } = inject(InjectionKeys.useCollectionState)();
|
||||
const { isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||
|
||||
const areInstructionsVisible = ref(false);
|
||||
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
|
||||
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
|
||||
currentState.value.collection.os,
|
||||
fileName.value,
|
||||
));
|
||||
|
||||
function saveCode() {
|
||||
saveCodeToDisk(fileName.value, currentState.value);
|
||||
areInstructionsVisible.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
isDesktopVersion: isDesktop,
|
||||
instructions,
|
||||
fileName,
|
||||
areInstructionsVisible,
|
||||
saveCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function getDownloadInstructions(
|
||||
os: OperatingSystem,
|
||||
fileName: string,
|
||||
): IInstructionListData | undefined {
|
||||
if (!hasInstructions(os)) {
|
||||
return undefined;
|
||||
}
|
||||
return getInstructions(os, fileName);
|
||||
}
|
||||
|
||||
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
||||
const content = state.code.current;
|
||||
const type = getType(state.collection.scripting.language);
|
||||
SaveFileDialog.saveFile(content, fileName, type);
|
||||
}
|
||||
|
||||
function getType(language: ScriptingLanguage) {
|
||||
switch (language) {
|
||||
case ScriptingLanguage.batchfile:
|
||||
return FileType.BatchFile;
|
||||
case ScriptingLanguage.shellscript:
|
||||
return FileType.ShellScript;
|
||||
default:
|
||||
throw new Error('unknown file type');
|
||||
}
|
||||
}
|
||||
function buildFileName(scripting: IScriptingDefinition) {
|
||||
const fileName = 'privacy-script';
|
||||
if (scripting.fileExtension) {
|
||||
return `${fileName}.${scripting.fileExtension}`;
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span class="code-wrapper">
|
||||
<span class="dollar">$</span>
|
||||
<code><slot /></code>
|
||||
<code ref="codeElement"><slot /></code>
|
||||
<TooltipWrapper>
|
||||
<AppIcon
|
||||
class="copy-button"
|
||||
@@ -16,10 +16,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useSlots } from 'vue';
|
||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||
import { defineComponent, shallowRef, inject } from 'vue';
|
||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -27,15 +27,25 @@ export default defineComponent({
|
||||
AppIcon,
|
||||
},
|
||||
setup() {
|
||||
const slots = useSlots();
|
||||
const { copyText } = inject(InjectionKeys.useClipboard)();
|
||||
|
||||
function copyCode() {
|
||||
const code = slots.default()[0].text;
|
||||
Clipboard.copyText(code);
|
||||
const codeElement = shallowRef<HTMLElement | undefined>();
|
||||
|
||||
async function copyCode() {
|
||||
const element = codeElement.value;
|
||||
if (!element) {
|
||||
throw new Error('Code element could not be found.');
|
||||
}
|
||||
const code = element.textContent;
|
||||
if (!code) {
|
||||
throw new Error('Code element does not contain any text.');
|
||||
}
|
||||
await copyText(code);
|
||||
}
|
||||
|
||||
return {
|
||||
copyCode,
|
||||
codeElement,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,168 +1,36 @@
|
||||
<template>
|
||||
<div class="container" v-if="hasCode">
|
||||
<IconButton
|
||||
v-if="canRun"
|
||||
text="Run"
|
||||
v-on:click="executeCode"
|
||||
icon-name="play"
|
||||
/>
|
||||
<IconButton
|
||||
:text="isDesktopVersion ? 'Save' : 'Download'"
|
||||
v-on:click="saveCode"
|
||||
:icon-name="isDesktopVersion ? 'floppy-disk' : 'file-arrow-down'"
|
||||
/>
|
||||
<IconButton
|
||||
text="Copy"
|
||||
v-on:click="copyCode"
|
||||
icon-name="copy"
|
||||
/>
|
||||
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
|
||||
<InstructionList :data="instructions" />
|
||||
</ModalDialog>
|
||||
<CodeRunButton class="code-button" />
|
||||
<CodeSaveButton class="code-button" />
|
||||
<CodeCopyButton class="code-button" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, computed, inject,
|
||||
defineComponent, computed, inject,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import InstructionList from './Instructions/InstructionList.vue';
|
||||
import IconButton from './IconButton.vue';
|
||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
||||
import CodeRunButton from './CodeRunButton.vue';
|
||||
import CodeCopyButton from './CodeCopyButton.vue';
|
||||
import CodeSaveButton from './Save/CodeSaveButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
InstructionList,
|
||||
ModalDialog,
|
||||
CodeRunButton,
|
||||
CodeCopyButton,
|
||||
CodeSaveButton,
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
currentState, currentContext, onStateChange,
|
||||
} = inject(InjectionKeys.useCollectionState)();
|
||||
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
|
||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
|
||||
|
||||
const areInstructionsVisible = ref(false);
|
||||
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
|
||||
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
|
||||
const hasCode = ref(false);
|
||||
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
|
||||
currentState.value.collection.os,
|
||||
fileName.value,
|
||||
));
|
||||
|
||||
async function copyCode() {
|
||||
const code = await getCurrentCode();
|
||||
Clipboard.copyText(code.current);
|
||||
}
|
||||
|
||||
function saveCode() {
|
||||
saveCodeToDisk(fileName.value, currentState.value);
|
||||
areInstructionsVisible.value = true;
|
||||
}
|
||||
|
||||
async function executeCode() {
|
||||
await runCode(currentContext);
|
||||
}
|
||||
|
||||
onStateChange((newState) => {
|
||||
updateCurrentCode(newState.code.current);
|
||||
subscribeToCodeChanges(newState.code);
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeToCodeChanges(code: IApplicationCode) {
|
||||
events.unsubscribeAllAndRegister([
|
||||
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
|
||||
]);
|
||||
}
|
||||
|
||||
function updateCurrentCode(code: string) {
|
||||
hasCode.value = code && code.length > 0;
|
||||
}
|
||||
|
||||
async function getCurrentCode(): Promise<IApplicationCode> {
|
||||
const { code } = currentContext.state;
|
||||
return code;
|
||||
}
|
||||
const hasCode = computed<boolean>(() => currentCode.value.length > 0);
|
||||
|
||||
return {
|
||||
isDesktopVersion: isDesktop,
|
||||
canRun,
|
||||
hasCode,
|
||||
instructions,
|
||||
fileName,
|
||||
areInstructionsVisible,
|
||||
copyCode,
|
||||
saveCode,
|
||||
executeCode,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function getDownloadInstructions(
|
||||
os: OperatingSystem,
|
||||
fileName: string,
|
||||
): IInstructionListData | undefined {
|
||||
if (!hasInstructions(os)) {
|
||||
return undefined;
|
||||
}
|
||||
return getInstructions(os, fileName);
|
||||
}
|
||||
|
||||
function getCanRunState(
|
||||
selectedOs: OperatingSystem,
|
||||
isDesktopVersion: boolean,
|
||||
hostOs: OperatingSystem,
|
||||
): boolean {
|
||||
const isRunningOnSelectedOs = selectedOs === hostOs;
|
||||
return isDesktopVersion && isRunningOnSelectedOs;
|
||||
}
|
||||
|
||||
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
||||
const content = state.code.current;
|
||||
const type = getType(state.collection.scripting.language);
|
||||
SaveFileDialog.saveFile(content, fileName, type);
|
||||
}
|
||||
|
||||
function getType(language: ScriptingLanguage) {
|
||||
switch (language) {
|
||||
case ScriptingLanguage.batchfile:
|
||||
return FileType.BatchFile;
|
||||
case ScriptingLanguage.shellscript:
|
||||
return FileType.ShellScript;
|
||||
default:
|
||||
throw new Error('unknown file type');
|
||||
}
|
||||
}
|
||||
function buildFileName(scripting: IScriptingDefinition) {
|
||||
const fileName = 'privacy-script';
|
||||
if (scripting.fileExtension) {
|
||||
return `${fileName}.${scripting.fileExtension}`;
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function runCode(context: IReadOnlyApplicationContext) {
|
||||
const runner = new CodeRunner();
|
||||
await runner.runCode(
|
||||
/* code: */ context.state.code.current,
|
||||
/* appName: */ context.app.info.name,
|
||||
/* fileExtension: */ context.state.collection.scripting.fileExtension,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -170,8 +38,10 @@ async function runCode(context: IReadOnlyApplicationContext) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
}
|
||||
.container > * + * {
|
||||
margin-left: 30px;
|
||||
.code-button {
|
||||
width: 10%;
|
||||
min-width: 90px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -185,14 +185,16 @@ function getDefaultCode(language: ScriptingLanguage): string {
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
::v-deep .code-area {
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
&__highlight {
|
||||
background-color: $color-secondary-light;
|
||||
position: absolute;
|
||||
:deep() {
|
||||
.code-area {
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
&__highlight {
|
||||
background-color: $color-secondary-light;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,8 +28,8 @@ $gap: 0.25rem;
|
||||
font-family: $font-normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.items {
|
||||
* + *::before {
|
||||
:deep(.items) {
|
||||
> * + *::before {
|
||||
content: '|';
|
||||
padding-right: $gap;
|
||||
padding-left: $gap;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<span> <!-- Parent wrapper allows adding content inside with CSS without making it clickable -->
|
||||
<span>
|
||||
<!--
|
||||
Parent wrapper allows `MenuOptionList` to safely add content inside
|
||||
such as adding content in `::before` block without making it clickable.
|
||||
-->
|
||||
<span
|
||||
v-bind:class="{
|
||||
disabled: !enabled,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { defineComponent, shallowRef } from 'vue';
|
||||
import SliderHandle from './SliderHandle.vue';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -45,7 +45,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const firstElement = ref<HTMLElement>();
|
||||
const firstElement = shallowRef<HTMLElement>();
|
||||
|
||||
function onResize(displacementX: number): void {
|
||||
const leftWidth = firstElement.value.offsetWidth + displacementX;
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, watch, computed,
|
||||
inject,
|
||||
inject, shallowRef,
|
||||
} from 'vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
@@ -95,7 +95,7 @@ export default defineComponent({
|
||||
|
||||
const isAnyChildSelected = ref(false);
|
||||
const areAllChildrenSelected = ref(false);
|
||||
const cardElement = ref<HTMLElement>();
|
||||
const cardElement = shallowRef<HTMLElement>();
|
||||
|
||||
const cardTitle = computed<string | undefined>(() => {
|
||||
if (!props.categoryId || !currentState.value) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export function hasDirective(el: Element): boolean {
|
||||
}
|
||||
|
||||
export const NonCollapsing: ObjectDirective<HTMLElement> = {
|
||||
inserted(el: HTMLElement) { // In Vue 3, use "mounted"
|
||||
mounted(el: HTMLElement) {
|
||||
el.setAttribute(attributeName, '');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import { defineComponent, computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: Boolean,
|
||||
modelValue: Boolean,
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -32,19 +32,19 @@ export default defineComponent({
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isChecked: boolean) => true,
|
||||
'update:modelValue': (isChecked: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const isChecked = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value: boolean) {
|
||||
if (value === props.value) {
|
||||
if (value === props.modelValue) {
|
||||
return;
|
||||
}
|
||||
emit('input', value);
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,14 +2,6 @@ import type { ReadOnlyTreeNode } from '../Node/TreeNode';
|
||||
|
||||
export interface TreeViewFilterEvent {
|
||||
readonly action: TreeViewFilterAction;
|
||||
/**
|
||||
* A simple numeric value to ensure uniqueness of each event.
|
||||
*
|
||||
* This property is used to guarantee that the watch function will trigger
|
||||
* even if the same filter action value is emitted consecutively.
|
||||
*/
|
||||
readonly timestamp: Date;
|
||||
|
||||
readonly predicate?: TreeViewFilterPredicate;
|
||||
}
|
||||
|
||||
@@ -25,7 +17,6 @@ export function createFilterTriggeredEvent(
|
||||
): TreeViewFilterEvent {
|
||||
return {
|
||||
action: TreeViewFilterAction.Triggered,
|
||||
timestamp: new Date(),
|
||||
predicate,
|
||||
};
|
||||
}
|
||||
@@ -33,6 +24,5 @@ export function createFilterTriggeredEvent(
|
||||
export function createFilterRemovedEvent(): TreeViewFilterEvent {
|
||||
return {
|
||||
action: TreeViewFilterAction.Removed,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,10 +184,7 @@ export default defineComponent({
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.#{$name}-enter,
|
||||
// Vue 2.X compatibility
|
||||
.#{$name}-enter-from,
|
||||
// Vue 3.X compatibility
|
||||
.#{$name}-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-2em);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
WatchSource, inject, ref, watch,
|
||||
WatchSource, inject, shallowRef, watch,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { ReadOnlyTreeNode } from './TreeNode';
|
||||
@@ -10,7 +10,7 @@ export function useNodeState(
|
||||
) {
|
||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
|
||||
const state = ref<TreeNodeStateDescriptor>();
|
||||
const state = shallowRef<TreeNodeStateDescriptor>();
|
||||
|
||||
watch(nodeWatcher, (node: ReadOnlyTreeNode) => {
|
||||
if (!node) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, onMounted, watch,
|
||||
ref, PropType,
|
||||
shallowRef, PropType,
|
||||
} from 'vue';
|
||||
import { TreeRootManager } from './TreeRoot/TreeRootManager';
|
||||
import TreeRoot from './TreeRoot/TreeRoot.vue';
|
||||
@@ -53,7 +53,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const treeContainerElement = ref<HTMLElement | undefined>();
|
||||
const treeContainerElement = shallowRef<HTMLElement | undefined>();
|
||||
|
||||
const tree = new TreeRootManager();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
WatchSource, watch, inject, readonly, ref,
|
||||
WatchSource, watch, inject, shallowReadonly, shallowRef,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { TreeRoot } from './TreeRoot/TreeRoot';
|
||||
@@ -8,8 +8,8 @@ import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
|
||||
export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
|
||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
|
||||
const tree = ref<TreeRoot | undefined>();
|
||||
const nodes = ref<QueryableNodes | undefined>();
|
||||
const tree = shallowRef<TreeRoot | undefined>();
|
||||
const nodes = shallowRef<QueryableNodes | undefined>();
|
||||
|
||||
watch(treeWatcher, (newTree) => {
|
||||
tree.value = newTree;
|
||||
@@ -22,6 +22,6 @@ export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
nodes: readonly(nodes),
|
||||
nodes: shallowReadonly(nodes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
WatchSource, inject, watch, ref,
|
||||
WatchSource, inject, watch, shallowRef,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||
@@ -17,7 +17,7 @@ export function useNodeStateChangeAggregator(
|
||||
const { nodes } = useTreeNodes(treeWatcher);
|
||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
|
||||
const onNodeChangeCallback = ref<NodeStateChangeEventCallback>();
|
||||
const onNodeChangeCallback = shallowRef<NodeStateChangeEventCallback>();
|
||||
|
||||
watch([
|
||||
() => nodes.value,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
computed, inject, readonly, ref,
|
||||
computed, inject, shallowReadonly, shallowRef, triggerRef,
|
||||
} from 'vue';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
@@ -15,7 +15,7 @@ export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
|
||||
});
|
||||
|
||||
return {
|
||||
selectedScriptNodeIds: readonly(selectedNodeIds),
|
||||
selectedScriptNodeIds: shallowReadonly(selectedNodeIds),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,18 +23,28 @@ function useSelectedScripts() {
|
||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||
|
||||
const selectedScripts = ref<readonly SelectedScript[]>([]);
|
||||
const selectedScripts = shallowRef<readonly SelectedScript[]>([]);
|
||||
|
||||
function updateSelectedScripts(newReference: readonly SelectedScript[]) {
|
||||
if (selectedScripts.value === newReference) {
|
||||
// Manually trigger update if the array was mutated using the same reference.
|
||||
// Array might have been mutated without changing the reference
|
||||
triggerRef(selectedScripts);
|
||||
} else {
|
||||
selectedScripts.value = newReference;
|
||||
}
|
||||
}
|
||||
|
||||
onStateChange((state) => {
|
||||
selectedScripts.value = state.selection.selectedScripts;
|
||||
updateSelectedScripts(state.selection.selectedScripts);
|
||||
events.unsubscribeAllAndRegister([
|
||||
state.selection.changed.on((scripts) => {
|
||||
selectedScripts.value = scripts;
|
||||
updateSelectedScripts(scripts);
|
||||
}),
|
||||
]);
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
selectedScripts: readonly(selectedScripts),
|
||||
selectedScripts: shallowReadonly(selectedScripts),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
Ref, inject, readonly, ref,
|
||||
Ref, inject, shallowReadonly, shallowRef,
|
||||
} from 'vue';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
@@ -21,7 +21,7 @@ export function useTreeViewFilterEvent() {
|
||||
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
|
||||
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
|
||||
|
||||
const latestFilterEvent = ref<TreeViewFilterEvent | undefined>(undefined);
|
||||
const latestFilterEvent = shallowRef<TreeViewFilterEvent | undefined>(undefined);
|
||||
|
||||
const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches(
|
||||
getNodeMetadata(node),
|
||||
@@ -36,7 +36,7 @@ export function useTreeViewFilterEvent() {
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
latestFilterEvent: readonly(latestFilterEvent),
|
||||
latestFilterEvent: shallowReadonly(latestFilterEvent),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Clipboard } from './Clipboard';
|
||||
|
||||
export type NavigatorClipboard = typeof globalThis.navigator.clipboard;
|
||||
|
||||
export class BrowserClipboard implements Clipboard {
|
||||
constructor(
|
||||
private readonly navigatorClipboard: NavigatorClipboard = globalThis.navigator.clipboard,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public async copyText(text: string): Promise<void> {
|
||||
await this.navigatorClipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface Clipboard {
|
||||
copyText(text: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { FunctionKeys } from '@/TypeHelpers';
|
||||
import { BrowserClipboard } from './BrowserClipboard';
|
||||
import { Clipboard } from './Clipboard';
|
||||
|
||||
export function useClipboard(clipboard: Clipboard = new BrowserClipboard()) {
|
||||
// Bind functions for direct use from destructured assignments such as `const { .. } = ...`.
|
||||
const functionKeys: readonly FunctionKeys<Clipboard>[] = ['copyText'];
|
||||
functionKeys.forEach((functionName) => {
|
||||
const fn = clipboard[functionName];
|
||||
clipboard[functionName] = fn.bind(clipboard);
|
||||
});
|
||||
return clipboard;
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
ref, computed, readonly,
|
||||
} from 'vue';
|
||||
import { shallowRef, shallowReadonly } from 'vue';
|
||||
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
||||
@@ -16,7 +14,7 @@ export function useCollectionState(
|
||||
throw new Error('missing events');
|
||||
}
|
||||
|
||||
const currentState = ref<ICategoryCollectionState>(context.state);
|
||||
const currentState = shallowRef<IReadOnlyCategoryCollectionState>(context.state);
|
||||
events.register([
|
||||
context.contextChanged.on((event) => {
|
||||
currentState.value = event.newState;
|
||||
@@ -66,8 +64,7 @@ export function useCollectionState(
|
||||
modifyCurrentContext,
|
||||
onStateChange,
|
||||
currentContext: context as IReadOnlyApplicationContext,
|
||||
currentState: readonly(computed<IReadOnlyCategoryCollectionState>(() => currentState.value)),
|
||||
events: events as IEventSubscriptionCollection,
|
||||
currentState: shallowReadonly(currentState),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
32
src/presentation/components/Shared/Hooks/UseCurrentCode.ts
Normal file
32
src/presentation/components/Shared/Hooks/UseCurrentCode.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ref } from 'vue';
|
||||
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
||||
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
|
||||
import { useCollectionState } from './UseCollectionState';
|
||||
|
||||
export function useCurrentCode(
|
||||
state: ReturnType<typeof useCollectionState>,
|
||||
events: IEventSubscriptionCollection,
|
||||
) {
|
||||
const { onStateChange } = state;
|
||||
|
||||
const currentCode = ref<string>('');
|
||||
|
||||
onStateChange((newState) => {
|
||||
updateCurrentCode(newState.code.current);
|
||||
subscribeToCodeChanges(newState.code);
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeToCodeChanges(code: IApplicationCode) {
|
||||
events.unsubscribeAllAndRegister([
|
||||
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
|
||||
]);
|
||||
}
|
||||
|
||||
function updateCurrentCode(newCode: string) {
|
||||
currentCode.value = newCode;
|
||||
}
|
||||
|
||||
return {
|
||||
currentCode,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div v-html="svgContent" class="inline-icon" />
|
||||
<div
|
||||
class="inline-icon"
|
||||
v-html="svgContent"
|
||||
@click="onClicked"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -18,10 +22,19 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
emits: [
|
||||
'click',
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
const useSvgLoaderHook = inject('useSvgLoaderHook', useSvgLoader);
|
||||
|
||||
const { svgContent } = useSvgLoaderHook(() => props.icon);
|
||||
return { svgContent };
|
||||
|
||||
function onClicked() {
|
||||
emit('click');
|
||||
}
|
||||
|
||||
return { svgContent, onClicked };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,7 +43,7 @@ export default defineComponent({
|
||||
<style lang="scss" scoped>
|
||||
.inline-icon {
|
||||
display: inline-block;
|
||||
::v-deep svg { // using ::v-deep because when v-html is used the content doesn't go through Vue's template compiler.
|
||||
:deep(svg) { // using :deep because when v-html is used the content doesn't go through Vue's template compiler.
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
overflow: visible;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
WatchSource, readonly, ref, watch,
|
||||
WatchSource, shallowReadonly, ref, watch,
|
||||
} from 'vue';
|
||||
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
|
||||
import { IconName } from './IconName';
|
||||
@@ -15,7 +15,7 @@ export function useSvgLoader(
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
svgContent: readonly(svgContent),
|
||||
svgContent: shallowReadonly(svgContent),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ const RawSvgLoaders = import.meta.glob('@/presentation/assets/icons/**/*.svg', {
|
||||
});
|
||||
|
||||
function modifySvg(svgSource: string): string {
|
||||
const parser = new DOMParser();
|
||||
const parser = new window.DOMParser();
|
||||
const doc = parser.parseFromString(svgSource, 'image/svg+xml');
|
||||
let svgRoot = doc.documentElement;
|
||||
svgRoot = removeSvgComments(svgRoot);
|
||||
|
||||
@@ -36,11 +36,11 @@ export default defineComponent({
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isOpen: boolean) => true,
|
||||
'update:modelValue': (isOpen: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
@@ -67,13 +67,13 @@ export default defineComponent({
|
||||
onModalFullyTransitionedOut(() => {
|
||||
isRendered.value = false;
|
||||
resetTransitionStatus();
|
||||
if (props.value) {
|
||||
emit('input', false);
|
||||
if (props.modelValue) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.value) {
|
||||
if (props.modelValue) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
@@ -99,8 +99,8 @@ export default defineComponent({
|
||||
|
||||
isOpen.value = false;
|
||||
|
||||
if (props.value) {
|
||||
emit('input', false);
|
||||
if (props.modelValue) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +115,8 @@ export default defineComponent({
|
||||
isOpen.value = true;
|
||||
});
|
||||
|
||||
if (!props.value) {
|
||||
emit('input', true);
|
||||
if (!props.modelValue) {
|
||||
emit('update:modelValue', true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { defineComponent, shallowRef } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -31,7 +31,7 @@ export default defineComponent({
|
||||
'transitionedOut',
|
||||
],
|
||||
setup(_, { emit }) {
|
||||
const modalElement = ref<HTMLElement>();
|
||||
const modalElement = shallowRef<HTMLElement>();
|
||||
|
||||
function onAfterTransitionLeave() {
|
||||
emit('transitionedOut');
|
||||
|
||||
@@ -28,21 +28,21 @@ export default defineComponent({
|
||||
},
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
input: (isOpen: boolean) => true,
|
||||
'update:modelValue': (isOpen: boolean) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const showDialog = computed({
|
||||
get: () => props.value,
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (value !== props.value) {
|
||||
emit('input', value);
|
||||
if (value !== props.modelValue) {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<div ref="containerElement" class="container">
|
||||
<slot ref="containerElement" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, onMounted, onBeforeUnmount,
|
||||
defineComponent, shallowRef, onMounted, onBeforeUnmount,
|
||||
} from 'vue';
|
||||
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
|
||||
|
||||
export default defineComponent({
|
||||
emits: {
|
||||
@@ -18,18 +19,22 @@ export default defineComponent({
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const containerElement = ref<HTMLElement>();
|
||||
const { resizeObserverReady } = useResizeObserverPolyfill();
|
||||
|
||||
const containerElement = shallowRef<HTMLElement>();
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let observer: ResizeObserver;
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
width = containerElement.value.offsetWidth;
|
||||
height = containerElement.value.offsetHeight;
|
||||
|
||||
observer = await initializeResizeObserver(updateSize);
|
||||
observer.observe(containerElement.value);
|
||||
resizeObserverReady.then(() => {
|
||||
observer = new ResizeObserver(updateSize);
|
||||
observer.observe(containerElement.value);
|
||||
});
|
||||
|
||||
fireChangeEvents();
|
||||
});
|
||||
@@ -38,16 +43,6 @@ export default defineComponent({
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
async function initializeResizeObserver(
|
||||
callback: ResizeObserverCallback,
|
||||
): Promise<ResizeObserver> {
|
||||
if ('ResizeObserver' in window) {
|
||||
return new window.ResizeObserver(callback);
|
||||
}
|
||||
const module = await import('@juggle/resize-observer');
|
||||
return new module.ResizeObserver(callback);
|
||||
}
|
||||
|
||||
function updateSize() {
|
||||
let sizeChanged = false;
|
||||
if (isWidthChanged()) {
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
useFloating, arrow, shift, flip, Placement, offset, Side, Coords,
|
||||
useFloating, arrow, shift, flip, Placement, offset, Side, Coords, autoUpdate,
|
||||
} from '@floating-ui/vue';
|
||||
import { defineComponent, ref, computed } from 'vue';
|
||||
import type { CSSProperties } from 'vue/types/jsx'; // In Vue 3.0 import from 'vue'
|
||||
import { defineComponent, shallowRef, computed } from 'vue';
|
||||
import { useResizeObserverPolyfill } from '@/presentation/components/Shared/Hooks/UseResizeObserverPolyfill';
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
const GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX = 2;
|
||||
const ARROW_SIZE_IN_PX = 4;
|
||||
@@ -35,16 +36,18 @@ const MARGIN_FROM_DOCUMENT_EDGE_IN_PX = 2;
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const tooltipDisplayElement = ref<HTMLElement | undefined>();
|
||||
const triggeringElement = ref<HTMLElement | undefined>();
|
||||
const arrowElement = ref<HTMLElement | undefined>();
|
||||
const placement = ref<Placement>('top');
|
||||
const tooltipDisplayElement = shallowRef<HTMLElement | undefined>();
|
||||
const triggeringElement = shallowRef<HTMLElement | undefined>();
|
||||
const arrowElement = shallowRef<HTMLElement | undefined>();
|
||||
const placement = shallowRef<Placement>('top');
|
||||
|
||||
useResizeObserverPolyfill();
|
||||
|
||||
const { floatingStyles, middlewareData } = useFloating(
|
||||
triggeringElement,
|
||||
tooltipDisplayElement,
|
||||
{
|
||||
placement: ref(placement),
|
||||
placement,
|
||||
middleware: [
|
||||
offset(ARROW_SIZE_IN_PX + GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX),
|
||||
/* Shifts the element along the specified axes in order to keep it in view. */
|
||||
@@ -56,6 +59,7 @@ export default defineComponent({
|
||||
flip(),
|
||||
arrow({ element: arrowElement }),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
},
|
||||
);
|
||||
const arrowStyles = computed<CSSProperties>(() => {
|
||||
@@ -101,9 +105,8 @@ function getArrowPositionStyles(
|
||||
} else if (y) { // either X or Y is calculated
|
||||
style.top = `${y}px`;
|
||||
}
|
||||
const oppositeSide = getCounterpartBoxOffsetProperty(placement) as never;
|
||||
// Cast to `never` due to ts(2590) from JSX import. Remove after migrating to Vue 3.0.
|
||||
style[oppositeSide] = `-${ARROW_SIZE_IN_PX}px`;
|
||||
const oppositeSide = getCounterpartBoxOffsetProperty(placement);
|
||||
style[oppositeSide.toString()] = `-${ARROW_SIZE_IN_PX}px`;
|
||||
return style;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
|
||||
import type { useAutoUnsubscribedEvents } from './components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||
import type { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import type { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import type { useRuntimeEnvironment } from '@/presentation/components/Shared/Hooks/UseRuntimeEnvironment';
|
||||
import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
|
||||
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
|
||||
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
export const InjectionKeys = {
|
||||
@@ -9,6 +11,8 @@ export const InjectionKeys = {
|
||||
useApplication: defineSingletonKey<ReturnType<typeof useApplication>>('useApplication'),
|
||||
useRuntimeEnvironment: defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment'),
|
||||
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
|
||||
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
|
||||
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
|
||||
};
|
||||
|
||||
function defineSingletonKey<T>(key: string): InjectionKey<T> {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Vue from 'vue';
|
||||
import { createApp } from 'vue';
|
||||
import App from './components/App.vue';
|
||||
import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper';
|
||||
|
||||
new ApplicationBootstrapper()
|
||||
.bootstrap(Vue);
|
||||
const app = createApp(App);
|
||||
|
||||
new Vue({
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app');
|
||||
await new ApplicationBootstrapper()
|
||||
.bootstrap(app);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
15
src/presentation/shims-tsx.d.ts
vendored
15
src/presentation/shims-tsx.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/presentation/shims-vue.d.ts
vendored
7
src/presentation/shims-vue.d.ts
vendored
@@ -1,7 +0,0 @@
|
||||
/* eslint-disable */
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue';
|
||||
const component: DefineComponent;
|
||||
export default component;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
describe, it, expect,
|
||||
} from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { defineComponent, shallowRef } from 'vue';
|
||||
import TreeView from '@/presentation/components/Scripts/View/Tree/TreeView/TreeView.vue';
|
||||
import { TreeInputNodeData } from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputNodeData';
|
||||
import { provideDependencies } from '@/presentation/bootstrapping/DependencyProvider';
|
||||
@@ -33,8 +33,8 @@ function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) {
|
||||
setup() {
|
||||
provideDependencies(new ApplicationContextStub());
|
||||
|
||||
const initialNodes = ref(initialNodeData);
|
||||
const selectedLeafNodeIds = ref<readonly string[]>([]);
|
||||
const initialNodes = shallowRef(initialNodeData);
|
||||
const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
|
||||
return {
|
||||
initialNodes,
|
||||
selectedLeafNodeIds,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach } from 'vitest';
|
||||
import { enableAutoDestroy } from '@vue/test-utils';
|
||||
import { enableAutoUnmount } from '@vue/test-utils';
|
||||
|
||||
enableAutoDestroy(afterEach);
|
||||
enableAutoUnmount(afterEach);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'
|
||||
import { UserFilter } from '@/application/Context/State/Filter/UserFilter';
|
||||
import { CategoryStub } from '@tests/unit/shared/Stubs/CategoryStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { FilterChangeDetailsStub } from '@tests/unit/shared/Stubs/FilterChangeDetailsStub';
|
||||
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
|
||||
import { FilterChange } from '@/application/Context/State/Filter/Event/FilterChange';
|
||||
import { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('UserFilter', () => {
|
||||
describe('clearFilter', () => {
|
||||
it('signals when removing filter', () => {
|
||||
// arrange
|
||||
const expectedChange = FilterChange.forClear();
|
||||
const expectedChange = FilterChangeDetailsStub.forClear();
|
||||
let actualChange: IFilterChangeDetails;
|
||||
const sut = new UserFilter(new CategoryCollectionStub());
|
||||
sut.filterChanged.on((change) => {
|
||||
|
||||
@@ -1,126 +1,295 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ExpressionRegexBuilder } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder';
|
||||
|
||||
const AllWhitespaceCharacters = ' \t\n\r\v\f\u00A0';
|
||||
|
||||
describe('ExpressionRegexBuilder', () => {
|
||||
describe('expectCharacters', () => {
|
||||
describe('escape single as expected', () => {
|
||||
const charactersToEscape = ['.', '$'];
|
||||
for (const character of charactersToEscape) {
|
||||
it(character, () => {
|
||||
expectRegex(
|
||||
// act
|
||||
describe('expectCharacters', () => {
|
||||
describe('escapes single character as expected', () => {
|
||||
const charactersToEscape = ['.', '$'];
|
||||
for (const character of charactersToEscape) {
|
||||
it(`escapes ${character} as expected`, () => expectMatch(
|
||||
character,
|
||||
(act) => act.expectCharacters(character),
|
||||
// assert
|
||||
`\\${character}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
it('escapes multiple as expected', () => {
|
||||
expectRegex(
|
||||
// act
|
||||
`${character}`,
|
||||
));
|
||||
}
|
||||
});
|
||||
it('escapes multiple characters as expected', () => expectMatch(
|
||||
'.I have no $$.',
|
||||
(act) => act.expectCharacters('.I have no $$.'),
|
||||
// assert
|
||||
'\\.I have no \\$\\$\\.',
|
||||
);
|
||||
});
|
||||
it('adds as expected', () => {
|
||||
expectRegex(
|
||||
// act
|
||||
(act) => act.expectCharacters('return as it is'),
|
||||
// assert
|
||||
'.I have no $$.',
|
||||
));
|
||||
it('adds characters as expected', () => expectMatch(
|
||||
'return as it is',
|
||||
);
|
||||
(act) => act.expectCharacters('return as it is'),
|
||||
'return as it is',
|
||||
));
|
||||
});
|
||||
});
|
||||
it('expectOneOrMoreWhitespaces', () => {
|
||||
expectRegex(
|
||||
// act
|
||||
describe('expectOneOrMoreWhitespaces', () => {
|
||||
it('matches one whitespace', () => expectMatch(
|
||||
' ',
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
// assert
|
||||
'\\s+',
|
||||
);
|
||||
' ',
|
||||
));
|
||||
it('matches multiple whitespaces', () => expectMatch(
|
||||
AllWhitespaceCharacters,
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('matches whitespaces inside text', () => expectMatch(
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('does not match non-whitespace characters', () => expectNonMatch(
|
||||
'a',
|
||||
(act) => act.expectOneOrMoreWhitespaces(),
|
||||
));
|
||||
});
|
||||
it('matchPipeline', () => {
|
||||
expectRegex(
|
||||
// act
|
||||
(act) => act.matchPipeline(),
|
||||
// assert
|
||||
'\\s*(\\|\\s*.+?)?',
|
||||
);
|
||||
describe('captureOptionalPipeline', () => {
|
||||
it('does not capture when no pipe is present', () => expectNonMatch(
|
||||
'noPipeHere',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
));
|
||||
it('captures when input starts with pipe', () => expectCapture(
|
||||
'| afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores without text before', () => expectCapture(
|
||||
'stuff before | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores without text before', () => expectCapture(
|
||||
'stuff before | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores whitespaces before the pipe', () => expectCapture(
|
||||
' | afterPipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| afterPipe',
|
||||
));
|
||||
it('ignores text after whitespace', () => expectCapture(
|
||||
'| first Pipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| first ',
|
||||
));
|
||||
describe('non-greedy matching', () => { // so the rest of the pattern can work
|
||||
it('non-letter character in pipe', () => expectCapture(
|
||||
'| firstPipe | sec0ndpipe',
|
||||
(act) => act.captureOptionalPipeline(),
|
||||
'| firstPipe ',
|
||||
));
|
||||
});
|
||||
});
|
||||
it('matchUntilFirstWhitespace', () => {
|
||||
expectRegex(
|
||||
// act
|
||||
(act) => act.matchUntilFirstWhitespace(),
|
||||
// assert
|
||||
'([^|\\s]+)',
|
||||
);
|
||||
it('matches until first whitespace', () => expectMatch(
|
||||
describe('captureUntilWhitespaceOrPipe', () => {
|
||||
it('captures until first whitespace', () => expectCapture(
|
||||
// arrange
|
||||
'first second',
|
||||
'first ',
|
||||
// act
|
||||
(act) => act.matchUntilFirstWhitespace(),
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'first',
|
||||
));
|
||||
});
|
||||
describe('matchMultilineAnythingExceptSurroundingWhitespaces', () => {
|
||||
it('returns expected regex', () => expectRegex(
|
||||
// act
|
||||
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'\\s*([\\S\\s]+?)\\s*',
|
||||
));
|
||||
it('matches single line', () => expectMatch(
|
||||
it('captures until first pipe', () => expectCapture(
|
||||
// arrange
|
||||
'single line',
|
||||
'first|',
|
||||
// act
|
||||
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'single line',
|
||||
'first',
|
||||
));
|
||||
it('matches single line without surrounding whitespaces', () => expectMatch(
|
||||
it('captures all without whitespace or pipe', () => expectCapture(
|
||||
// arrange
|
||||
' single line\t',
|
||||
'all',
|
||||
// act
|
||||
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
(act) => act.captureUntilWhitespaceOrPipe(),
|
||||
// assert
|
||||
'single line',
|
||||
));
|
||||
it('matches multiple lines', () => expectMatch(
|
||||
// arrange
|
||||
'first line\nsecond line',
|
||||
// act
|
||||
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line',
|
||||
));
|
||||
it('matches multiple lines without surrounding whitespaces', () => expectMatch(
|
||||
// arrange
|
||||
' first line\nsecond line\t',
|
||||
// act
|
||||
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line',
|
||||
'all',
|
||||
));
|
||||
});
|
||||
it('expectExpressionStart', () => {
|
||||
expectRegex(
|
||||
// act
|
||||
describe('captureMultilineAnythingExceptSurroundingWhitespaces', () => {
|
||||
describe('single line', () => {
|
||||
it('captures a line without surrounding whitespaces', () => expectCapture(
|
||||
// arrange
|
||||
'line',
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'line',
|
||||
));
|
||||
it('captures a line with internal whitespaces intact', () => expectCapture(
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
`start${AllWhitespaceCharacters}end`,
|
||||
));
|
||||
it('excludes surrounding whitespaces', () => expectCapture(
|
||||
// arrange
|
||||
`${AllWhitespaceCharacters}single line\t`,
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'single line',
|
||||
));
|
||||
});
|
||||
describe('multiple lines', () => {
|
||||
it('captures text across multiple lines', () => expectCapture(
|
||||
// arrange
|
||||
'first line\nsecond line\r\nthird-line',
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line\r\nthird-line',
|
||||
));
|
||||
it('captures text with empty lines in between', () => expectCapture(
|
||||
'start\n\nend',
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
'start\n\nend',
|
||||
));
|
||||
it('excludes surrounding whitespaces from multiline text', () => expectCapture(
|
||||
// arrange
|
||||
` first line\nsecond line${AllWhitespaceCharacters}`,
|
||||
// act
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
// assert
|
||||
'first line\nsecond line',
|
||||
));
|
||||
});
|
||||
describe('edge cases', () => {
|
||||
it('does not capture for input with only whitespaces', () => expectNonCapture(
|
||||
AllWhitespaceCharacters,
|
||||
(act) => act.captureMultilineAnythingExceptSurroundingWhitespaces(),
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('expectExpressionStart', () => {
|
||||
it('matches expression start without trailing whitespaces', () => expectMatch(
|
||||
'{{expression',
|
||||
(act) => act.expectExpressionStart(),
|
||||
// assert
|
||||
'{{\\s*',
|
||||
);
|
||||
'{{',
|
||||
));
|
||||
it('matches expression start with trailing whitespaces', () => expectMatch(
|
||||
`{{${AllWhitespaceCharacters}expression`,
|
||||
(act) => act.expectExpressionStart(),
|
||||
`{{${AllWhitespaceCharacters}`,
|
||||
));
|
||||
it('does not match whitespaces not directly after expression start', () => expectMatch(
|
||||
' {{expression',
|
||||
(act) => act.expectExpressionStart(),
|
||||
'{{',
|
||||
));
|
||||
it('does not match if expression start is not present', () => expectNonMatch(
|
||||
'noExpressionStartHere',
|
||||
(act) => act.expectExpressionStart(),
|
||||
));
|
||||
});
|
||||
it('expectExpressionEnd', () => {
|
||||
expectRegex(
|
||||
// act
|
||||
describe('expectExpressionEnd', () => {
|
||||
it('matches expression end without preceding whitespaces', () => expectMatch(
|
||||
'expression}}',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
// assert
|
||||
'\\s*}}',
|
||||
);
|
||||
'}}',
|
||||
));
|
||||
it('matches expression end with preceding whitespaces', () => expectMatch(
|
||||
`expression${AllWhitespaceCharacters}}}`,
|
||||
(act) => act.expectExpressionEnd(),
|
||||
`${AllWhitespaceCharacters}}}`,
|
||||
));
|
||||
it('does not capture whitespaces not directly before expression end', () => expectMatch(
|
||||
'expression}} ',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
'}}',
|
||||
));
|
||||
it('does not match if expression end is not present', () => expectNonMatch(
|
||||
'noExpressionEndHere',
|
||||
(act) => act.expectExpressionEnd(),
|
||||
));
|
||||
});
|
||||
describe('expectOptionalWhitespaces', () => {
|
||||
describe('matching', () => {
|
||||
it('matches multiple Unix lines', () => expectMatch(
|
||||
// arrange
|
||||
'\n\n',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\n\n',
|
||||
));
|
||||
it('matches multiple Windows lines', () => expectMatch(
|
||||
// arrange
|
||||
'\r\n',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\r\n',
|
||||
));
|
||||
it('matches multiple spaces', () => expectMatch(
|
||||
// arrange
|
||||
' ',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
' ',
|
||||
));
|
||||
it('matches horizontal and vertical tabs', () => expectMatch(
|
||||
// arrange
|
||||
'\t\v',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\t\v',
|
||||
));
|
||||
it('matches form feed character', () => expectMatch(
|
||||
// arrange
|
||||
'\f',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\f',
|
||||
));
|
||||
it('matches a non-breaking space character', () => expectMatch(
|
||||
// arrange
|
||||
'\u00A0',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\u00A0',
|
||||
));
|
||||
it('matches a combination of whitespace characters', () => expectMatch(
|
||||
// arrange
|
||||
AllWhitespaceCharacters,
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
AllWhitespaceCharacters,
|
||||
));
|
||||
it('matches whitespace characters on different positions', () => expectMatch(
|
||||
// arrange
|
||||
'\ta\nb\rc\v',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
// assert
|
||||
'\t\n\r\v',
|
||||
));
|
||||
});
|
||||
describe('non-matching', () => {
|
||||
it('a non-whitespace character', () => expectNonMatch(
|
||||
// arrange
|
||||
'a',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
));
|
||||
it('multiple non-whitespace characters', () => expectNonMatch(
|
||||
// arrange
|
||||
'abc',
|
||||
// act
|
||||
(act) => act.expectOptionalWhitespaces(),
|
||||
));
|
||||
});
|
||||
});
|
||||
describe('buildRegExp', () => {
|
||||
it('sets global flag', () => {
|
||||
@@ -134,84 +303,126 @@ describe('ExpressionRegexBuilder', () => {
|
||||
expect(actual).to.equal(expected);
|
||||
});
|
||||
describe('can combine multiple parts', () => {
|
||||
it('with', () => {
|
||||
expectRegex(
|
||||
(sut) => sut
|
||||
// act
|
||||
// {{ with $variable }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('with')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('$')
|
||||
.matchUntilFirstWhitespace()
|
||||
.expectExpressionEnd()
|
||||
// scope
|
||||
.matchMultilineAnythingExceptSurroundingWhitespaces()
|
||||
// {{ end }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('end')
|
||||
.expectExpressionEnd(),
|
||||
// assert
|
||||
'{{\\s*with\\s+\\$([^|\\s]+)\\s*}}\\s*([\\S\\s]+?)\\s*{{\\s*end\\s*}}',
|
||||
);
|
||||
});
|
||||
it('scoped substitution', () => {
|
||||
expectRegex(
|
||||
(sut) => sut
|
||||
// act
|
||||
.expectExpressionStart().expectCharacters('.')
|
||||
.matchPipeline()
|
||||
.expectExpressionEnd(),
|
||||
// assert
|
||||
'{{\\s*\\.\\s*(\\|\\s*.+?)?\\s*}}',
|
||||
);
|
||||
});
|
||||
it('parameter substitution', () => {
|
||||
expectRegex(
|
||||
(sut) => sut
|
||||
// act
|
||||
.expectExpressionStart().expectCharacters('$')
|
||||
.matchUntilFirstWhitespace()
|
||||
.matchPipeline()
|
||||
.expectExpressionEnd(),
|
||||
// assert
|
||||
'{{\\s*\\$([^|\\s]+)\\s*(\\|\\s*.+?)?\\s*}}',
|
||||
);
|
||||
});
|
||||
it('combines character and whitespace expectations', () => expectMatch(
|
||||
'abc def',
|
||||
(act) => act
|
||||
.expectCharacters('abc')
|
||||
.expectOneOrMoreWhitespaces()
|
||||
.expectCharacters('def'),
|
||||
'abc def',
|
||||
));
|
||||
it('captures optional pipeline and text after it', () => expectCapture(
|
||||
'abc | def',
|
||||
(act) => act
|
||||
.expectCharacters('abc ')
|
||||
.captureOptionalPipeline(),
|
||||
'| def',
|
||||
));
|
||||
it('combines multiline capture with optional whitespaces', () => expectCapture(
|
||||
'\n abc \n',
|
||||
(act) => act
|
||||
.expectOptionalWhitespaces()
|
||||
.captureMultilineAnythingExceptSurroundingWhitespaces()
|
||||
.expectOptionalWhitespaces(),
|
||||
'abc',
|
||||
));
|
||||
it('combines expression start, optional whitespaces, and character expectation', () => expectMatch(
|
||||
'{{ abc',
|
||||
(act) => act
|
||||
.expectExpressionStart()
|
||||
.expectOptionalWhitespaces()
|
||||
.expectCharacters('abc'),
|
||||
'{{ abc',
|
||||
));
|
||||
it('combines character expectation, optional whitespaces, and expression end', () => expectMatch(
|
||||
'abc }}',
|
||||
(act) => act
|
||||
.expectCharacters('abc')
|
||||
.expectOptionalWhitespaces()
|
||||
.expectExpressionEnd(),
|
||||
'abc }}',
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function expectRegex(
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expected: string,
|
||||
) {
|
||||
enum MatchGroupIndex {
|
||||
FullMatch = 0,
|
||||
FirstCapturingGroup = 1,
|
||||
}
|
||||
|
||||
function expectCapture(
|
||||
input: string,
|
||||
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expectedCombinedCaptures: string | undefined,
|
||||
): void {
|
||||
// arrange
|
||||
const sut = new ExpressionRegexBuilder();
|
||||
const matchGroupIndex = MatchGroupIndex.FirstCapturingGroup;
|
||||
// act
|
||||
const actual = act(sut).buildRegExp().source;
|
||||
// assert
|
||||
expect(actual).to.equal(expected);
|
||||
expectMatch(input, act, expectedCombinedCaptures, matchGroupIndex);
|
||||
}
|
||||
|
||||
function expectNonMatch(
|
||||
input: string,
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
matchGroupIndex = MatchGroupIndex.FullMatch,
|
||||
): void {
|
||||
expectMatch(input, act, undefined, matchGroupIndex);
|
||||
}
|
||||
|
||||
function expectNonCapture(
|
||||
input: string,
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
): void {
|
||||
expectNonMatch(input, act, MatchGroupIndex.FirstCapturingGroup);
|
||||
}
|
||||
|
||||
function expectMatch(
|
||||
input: string,
|
||||
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expectedMatch: string,
|
||||
) {
|
||||
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
|
||||
expectedCombinedMatches: string | undefined,
|
||||
matchGroupIndex = MatchGroupIndex.FullMatch,
|
||||
): void {
|
||||
// arrange
|
||||
const [startMarker, endMarker] = [randomUUID(), randomUUID()];
|
||||
const markedInput = `${startMarker}${input}${endMarker}`;
|
||||
const builder = new ExpressionRegexBuilder()
|
||||
.expectCharacters(startMarker);
|
||||
act(builder);
|
||||
const markedRegex = builder.expectCharacters(endMarker).buildRegExp();
|
||||
const regexBuilder = new ExpressionRegexBuilder();
|
||||
act(regexBuilder);
|
||||
const regex = regexBuilder.buildRegExp();
|
||||
// act
|
||||
const match = Array.from(markedInput.matchAll(markedRegex))
|
||||
.filter((matches) => matches.length > 1)
|
||||
.map((matches) => matches[1])
|
||||
.filter(Boolean)
|
||||
.join();
|
||||
const allMatchGroups = Array.from(input.matchAll(regex));
|
||||
// assert
|
||||
expect(match).to.equal(expectedMatch);
|
||||
const actualMatches = allMatchGroups
|
||||
.filter((matches) => matches.length > matchGroupIndex)
|
||||
.map((matches) => matches[matchGroupIndex])
|
||||
.filter(Boolean) // matchAll returns `""` for full matches, `null` for capture groups
|
||||
.flat();
|
||||
const actualCombinedMatches = actualMatches.length ? actualMatches.join('') : undefined;
|
||||
expect(actualCombinedMatches).equal(
|
||||
expectedCombinedMatches,
|
||||
[
|
||||
'\n\n---',
|
||||
'Expected combined matches:',
|
||||
getTestDataText(expectedCombinedMatches),
|
||||
'Actual combined matches:',
|
||||
getTestDataText(actualCombinedMatches),
|
||||
'Input:',
|
||||
getTestDataText(input),
|
||||
'Regex:',
|
||||
getTestDataText(regex.toString()),
|
||||
'All match groups:',
|
||||
getTestDataText(JSON.stringify(allMatchGroups)),
|
||||
`Match index in group: ${matchGroupIndex}`,
|
||||
'---\n\n',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function getTestDataText(data: string | undefined): string {
|
||||
const outputPrefix = '\t> ';
|
||||
if (data === undefined) {
|
||||
return `${outputPrefix}undefined (no matches)`;
|
||||
}
|
||||
const getLiteralString = (text: string) => JSON.stringify(text).slice(1, -1);
|
||||
const text = `${outputPrefix}\`${getLiteralString(data)}\``;
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -4,25 +4,26 @@ import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressi
|
||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||
import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/ExpressionEvaluationContextStub';
|
||||
import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
|
||||
export class SyntaxParserTestsRunner {
|
||||
constructor(private readonly sut: IExpressionParser) {
|
||||
}
|
||||
|
||||
public expectPosition(...testCases: IExpectPositionTestCase[]) {
|
||||
public expectPosition(...testCases: ExpectPositionTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// act
|
||||
const expressions = this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
const actual = expressions.map((e) => e.position);
|
||||
expect(actual).to.deep.equal(testCase.expected);
|
||||
expect(scrambledEqual(actual, testCase.expected));
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectNoMatch(...testCases: INoMatchTestCase[]) {
|
||||
public expectNoMatch(...testCases: NoMatchTestScenario[]) {
|
||||
this.expectPosition(...testCases.map((testCase) => ({
|
||||
name: testCase.name,
|
||||
code: testCase.code,
|
||||
@@ -30,7 +31,7 @@ export class SyntaxParserTestsRunner {
|
||||
})));
|
||||
}
|
||||
|
||||
public expectResults(...testCases: IExpectResultTestCase[]) {
|
||||
public expectResults(...testCases: ExpectResultTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
@@ -47,7 +48,21 @@ export class SyntaxParserTestsRunner {
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectPipeHits(data: IExpectPipeHitTestData) {
|
||||
public expectThrows(...testCases: ExpectThrowsTestScenario[]) {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
// arrange
|
||||
const { expectedError } = testCase;
|
||||
// act
|
||||
const act = () => this.sut.findExpressions(testCase.code);
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public expectPipeHits(data: ExpectPipeHitTestScenario) {
|
||||
for (const validPipePart of PipeTestCases.ValidValues) {
|
||||
this.expectHitPipePart(validPipePart, data);
|
||||
}
|
||||
@@ -56,7 +71,7 @@ export class SyntaxParserTestsRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private expectHitPipePart(pipeline: string, data: IExpectPipeHitTestData) {
|
||||
private expectHitPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
|
||||
it(`"${pipeline}" hits`, () => {
|
||||
// arrange
|
||||
const expectedPipePart = pipeline.trim();
|
||||
@@ -73,14 +88,14 @@ export class SyntaxParserTestsRunner {
|
||||
// assert
|
||||
expect(expressions).has.lengthOf(1);
|
||||
expect(pipelineCompiler.compileHistory).has.lengthOf(1);
|
||||
const actualPipeNames = pipelineCompiler.compileHistory[0].pipeline;
|
||||
const actualPipePart = pipelineCompiler.compileHistory[0].pipeline;
|
||||
const actualValue = pipelineCompiler.compileHistory[0].value;
|
||||
expect(actualPipeNames).to.equal(expectedPipePart);
|
||||
expect(actualPipePart).to.equal(expectedPipePart);
|
||||
expect(actualValue).to.equal(data.parameterValue);
|
||||
});
|
||||
}
|
||||
|
||||
private expectMissPipePart(pipeline: string, data: IExpectPipeHitTestData) {
|
||||
private expectMissPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
|
||||
it(`"${pipeline}" misses`, () => {
|
||||
// arrange
|
||||
const args = new FunctionCallArgumentCollectionStub()
|
||||
@@ -98,42 +113,51 @@ export class SyntaxParserTestsRunner {
|
||||
});
|
||||
}
|
||||
}
|
||||
interface IExpectResultTestCase {
|
||||
name: string;
|
||||
code: string;
|
||||
args: (builder: FunctionCallArgumentCollectionStub) => FunctionCallArgumentCollectionStub;
|
||||
expected: readonly string[];
|
||||
|
||||
interface ExpectResultTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly args: (
|
||||
builder: FunctionCallArgumentCollectionStub,
|
||||
) => FunctionCallArgumentCollectionStub;
|
||||
readonly expected: readonly string[];
|
||||
}
|
||||
|
||||
interface IExpectPositionTestCase {
|
||||
name: string;
|
||||
code: string;
|
||||
expected: readonly ExpressionPosition[];
|
||||
interface ExpectThrowsTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly expectedError: string;
|
||||
}
|
||||
|
||||
interface INoMatchTestCase {
|
||||
name: string;
|
||||
code: string;
|
||||
interface ExpectPositionTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
readonly expected: readonly ExpressionPosition[];
|
||||
}
|
||||
|
||||
interface IExpectPipeHitTestData {
|
||||
codeBuilder: (pipeline: string) => string;
|
||||
parameterName: string;
|
||||
parameterValue: string;
|
||||
interface NoMatchTestScenario {
|
||||
readonly name: string;
|
||||
readonly code: string;
|
||||
}
|
||||
|
||||
interface ExpectPipeHitTestScenario {
|
||||
readonly codeBuilder: (pipeline: string) => string;
|
||||
readonly parameterName: string;
|
||||
readonly parameterValue: string;
|
||||
}
|
||||
|
||||
const PipeTestCases = {
|
||||
ValidValues: [
|
||||
// Single pipe with different whitespace combinations
|
||||
' | pipe1', ' |pipe1', '|pipe1', ' |pipe1', ' | pipe1',
|
||||
' | pipe', ' |pipe', '|pipe', ' |pipe', ' | pipe',
|
||||
|
||||
// Double pipes with different whitespace combinations
|
||||
' | pipe1 | pipe2', '| pipe1|pipe2', '|pipe1|pipe2', ' |pipe1 |pipe2', '| pipe1 | pipe2| pipe3 |pipe4',
|
||||
|
||||
// Wrong cases, but should match anyway and let pipelineCompiler throw errors
|
||||
'| pip€', '| pip{e} ',
|
||||
' | pipeFirst | pipeSecond', '| pipeFirst|pipeSecond', '|pipeFirst|pipeSecond', ' |pipeFirst |pipeSecond', '| pipeFirst | pipeSecond| pipeThird |pipeFourth',
|
||||
],
|
||||
InvalidValues: [
|
||||
' pipe1 |pipe2', ' pipe1',
|
||||
' withoutPipeBefore |pipe', ' withoutPipeBefore',
|
||||
|
||||
// It's OK to match them (move to valid values if needed) to let compiler throw instead.
|
||||
'| pip€', '| pip{e} ', '| pipeWithNumber55', '| pipe with whitespace',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -7,15 +7,15 @@ import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
|
||||
describe('WithParser', () => {
|
||||
const sut = new WithParser();
|
||||
const runner = new SyntaxParserTestsRunner(sut);
|
||||
describe('finds as expected', () => {
|
||||
describe('correctly identifies `with` syntax', () => {
|
||||
runner.expectPosition(
|
||||
{
|
||||
name: 'when no scope is not used',
|
||||
name: 'when no context variable is not used',
|
||||
code: 'hello {{ with $parameter }}no usage{{ end }} here',
|
||||
expected: [new ExpressionPosition(6, 44)],
|
||||
},
|
||||
{
|
||||
name: 'when scope is used',
|
||||
name: 'when context variable is used',
|
||||
code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})',
|
||||
expected: [new ExpressionPosition(11, 53)],
|
||||
},
|
||||
@@ -25,38 +25,70 @@ describe('WithParser', () => {
|
||||
expected: [new ExpressionPosition(7, 51), new ExpressionPosition(61, 99)],
|
||||
},
|
||||
{
|
||||
name: 'tolerate lack of whitespaces',
|
||||
name: 'when nested',
|
||||
code: 'outer: {{ with $outer }}outer value with context variable: {{ . }}, inner: {{ with $inner }}inner value{{ end }}.{{ end }}',
|
||||
expected: [
|
||||
/* outer: */ new ExpressionPosition(7, 122),
|
||||
/* inner: */ new ExpressionPosition(77, 112),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'whitespaces: tolerate lack of whitespaces',
|
||||
code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}',
|
||||
expected: [new ExpressionPosition(15, 55)],
|
||||
},
|
||||
{
|
||||
name: 'match multiline text',
|
||||
name: 'newlines: match multiline text',
|
||||
code: 'non related line\n{{ with $middleLine }}\nline before value\n{{ . }}\nline after value\n{{ end }}\nnon related line',
|
||||
expected: [new ExpressionPosition(17, 92)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: does not match newlines before',
|
||||
code: '\n{{ with $unimportant }}Text{{ end }}',
|
||||
expected: [new ExpressionPosition(1, 37)],
|
||||
},
|
||||
{
|
||||
name: 'newlines: does not match newlines after',
|
||||
code: '{{ with $unimportant }}Text{{ end }}\n',
|
||||
expected: [new ExpressionPosition(0, 36)],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('throws with incorrect `with` syntax', () => {
|
||||
runner.expectThrows(
|
||||
{
|
||||
name: 'incorrect `with`: whitespace after dollar sign inside `with` statement',
|
||||
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `with`: whitespace before dollar sign inside `with` statement',
|
||||
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `with`: missing `with` statement',
|
||||
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `end`: missing `end` statement',
|
||||
code: '{{ with $parameter}}value: {{ . }}{{ fin }}',
|
||||
expectedError: 'Missing `end` statement, forgot `{{ end }}?',
|
||||
},
|
||||
{
|
||||
name: 'incorrect `end`: used without `with`',
|
||||
code: 'Value {{ end }}',
|
||||
expectedError: 'Redundant `end` statement, missing `with`?',
|
||||
},
|
||||
{
|
||||
name: 'incorrect "context variable": used without `with`',
|
||||
code: 'Value: {{ . }}',
|
||||
expectedError: 'Context variable before `with` statement.',
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('ignores when syntax is wrong', () => {
|
||||
describe('ignores expression if "with" syntax is wrong', () => {
|
||||
runner.expectNoMatch(
|
||||
{
|
||||
name: 'does not tolerate whitespace after with',
|
||||
code: '{{with $ parameter}}value: {{ . }}{{ end }}',
|
||||
},
|
||||
{
|
||||
name: 'does not tolerate whitespace before dollar',
|
||||
code: '{{ with$parameter}}value: {{ . }}{{ end }}',
|
||||
},
|
||||
{
|
||||
name: 'wrong text at scope end',
|
||||
code: '{{ with$parameter}}value: {{ . }}{{ fin }}',
|
||||
},
|
||||
{
|
||||
name: 'wrong text at expression start',
|
||||
code: '{{ when $parameter}}value: {{ . }}{{ end }}',
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('does not render argument if substitution syntax is wrong', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
@@ -83,54 +115,73 @@ describe('WithParser', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('renders scope conditionally', () => {
|
||||
describe('does not render scope if argument is undefined', () => {
|
||||
runner.expectResults(
|
||||
...getAbsentStringTestCases().map((testCase) => ({
|
||||
name: `does not render when value is "${testCase.valueName}"`,
|
||||
code: '{{ with $parameter }}dark{{ end }} ',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', testCase.absentValue),
|
||||
expected: [''],
|
||||
})),
|
||||
{
|
||||
name: 'does not render when argument is not provided',
|
||||
code: '{{ with $parameter }}dark{{ end }}',
|
||||
args: (args) => args,
|
||||
expected: [''],
|
||||
},
|
||||
);
|
||||
describe('scope rendering', () => {
|
||||
describe('conditional rendering based on argument value', () => {
|
||||
describe('does not render scope', () => {
|
||||
runner.expectResults(
|
||||
...getAbsentStringTestCases().map((testCase) => ({
|
||||
name: `does not render when value is "${testCase.valueName}"`,
|
||||
code: '{{ with $parameter }}dark{{ end }} ',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', testCase.absentValue),
|
||||
expected: [''],
|
||||
})),
|
||||
{
|
||||
name: 'does not render when argument is not provided',
|
||||
code: '{{ with $parameter }}dark{{ end }}',
|
||||
args: (args) => args,
|
||||
expected: [''],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('renders scope', () => {
|
||||
runner.expectResults(
|
||||
...getAbsentStringTestCases().map((testCase) => ({
|
||||
name: `does not render when value is "${testCase.valueName}"`,
|
||||
code: '{{ with $parameter }}dark{{ end }} ',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', testCase.absentValue),
|
||||
expected: [''],
|
||||
})),
|
||||
{
|
||||
name: 'does not render when argument is not provided',
|
||||
code: '{{ with $parameter }}dark{{ end }}',
|
||||
args: (args) => args,
|
||||
expected: [''],
|
||||
},
|
||||
{
|
||||
name: 'renders scope even if value is not used',
|
||||
code: '{{ with $parameter }}Hello world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when it has value',
|
||||
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when whitespaces around brackets are missing',
|
||||
code: '{{ with $parameter }}{{.}} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value multiple times when it\'s used multiple times',
|
||||
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('letterL', 'l'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('render scope when variable has value', () => {
|
||||
describe('whitespace handling inside scope', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'renders scope even if value is not used',
|
||||
code: '{{ with $parameter }}Hello world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when it has value',
|
||||
code: '{{ with $parameter }}{{ . }} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value when whitespaces around brackets are missing',
|
||||
code: '{{ with $parameter }}{{.}} world!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value multiple times when it\'s used multiple times',
|
||||
code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('letterL', 'l'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'renders value in multi-lined text',
|
||||
code: '{{ with $middleLine }}line before value\n{{ . }}\nline after value{{ end }}',
|
||||
@@ -145,42 +196,71 @@ describe('WithParser', () => {
|
||||
.withArgument('middleLine', 'value line'),
|
||||
expected: ['line before value\nvalue line\nline after value'],
|
||||
},
|
||||
{
|
||||
name: 'does not render trailing whitespace after value',
|
||||
code: '{{ with $parameter }}{{ . }}! {{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render trailing newline after value',
|
||||
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading newline before value',
|
||||
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading whitespaces before value',
|
||||
code: '{{ with $parameter }} {{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading newline and whitespaces before value',
|
||||
code: '{{ with $parameter }}\r\n {{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('nested with statements', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'renders nested with statements correctly',
|
||||
code: '{{ with $outer }}Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: {{ . }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('outer', 'OuterValue')
|
||||
.withArgument('inner', 'InnerValue'),
|
||||
expected: [
|
||||
'Inner: InnerValue',
|
||||
'Outer: {{ with $inner }}Inner: {{ . }}{{ end }}, Outer again: OuterValue',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'renders nested with statements with context variables',
|
||||
code: '{{ with $outer }}{{ with $inner }}{{ . }}{{ . }}{{ end }}{{ . }}{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('outer', 'O')
|
||||
.withArgument('inner', 'I'),
|
||||
expected: [
|
||||
'II',
|
||||
'{{ with $inner }}{{ . }}{{ . }}{{ end }}O',
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('ignores trailing and leading whitespaces and newlines inside scope', () => {
|
||||
runner.expectResults(
|
||||
{
|
||||
name: 'does not render trailing whitespace after value',
|
||||
code: '{{ with $parameter }}{{ . }}! {{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render trailing newline after value',
|
||||
code: '{{ with $parameter }}{{ . }}!\r\n{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading newline before value',
|
||||
code: '{{ with $parameter }}\r\n{{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
{
|
||||
name: 'does not render leading whitespace before value',
|
||||
code: '{{ with $parameter }} {{ . }}!{{ end }}',
|
||||
args: (args) => args
|
||||
.withArgument('parameter', 'Hello world'),
|
||||
expected: ['Hello world!'],
|
||||
},
|
||||
);
|
||||
});
|
||||
describe('compiles pipes in scope as expected', () => {
|
||||
describe('pipe behavior', () => {
|
||||
runner.expectPipeHits({
|
||||
codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`,
|
||||
parameterName: 'argument',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ describe('DependencyProvider', () => {
|
||||
useApplication: createSingletonTests(),
|
||||
useRuntimeEnvironment: createSingletonTests(),
|
||||
useAutoUnsubscribedEvents: createTransientTests(),
|
||||
useClipboard: createTransientTests(),
|
||||
useCurrentCode: createTransientTests(),
|
||||
};
|
||||
Object.entries(testCases).forEach(([key, runTests]) => {
|
||||
describe(`Key: "${key}"`, () => {
|
||||
|
||||
@@ -3,13 +3,13 @@ import { AppInitializationLogger } from '@/presentation/bootstrapping/Modules/Ap
|
||||
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
|
||||
|
||||
describe('AppInitializationLogger', () => {
|
||||
it('logs the app initialization marker upon bootstrap', () => {
|
||||
it('logs the app initialization marker upon bootstrap', async () => {
|
||||
// arrange
|
||||
const marker = '[APP_INIT]';
|
||||
const loggerStub = new LoggerStub();
|
||||
const sut = new AppInitializationLogger(loggerStub);
|
||||
// act
|
||||
sut.bootstrap();
|
||||
await sut.bootstrap();
|
||||
// assert
|
||||
expect(loggerStub.callHistory).to.have.lengthOf(1);
|
||||
expect(loggerStub.callHistory[0].args).to.have.lengthOf(1);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions';
|
||||
import { RuntimeSanityValidator } from '@/presentation/bootstrapping/Modules/RuntimeSanityValidator';
|
||||
import { expectDoesNotThrowAsync, expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
|
||||
describe('RuntimeSanityValidator', () => {
|
||||
it('calls validator with correct options upon bootstrap', () => {
|
||||
it('calls validator with correct options upon bootstrap', async () => {
|
||||
// arrange
|
||||
const expectedOptions: ISanityCheckOptions = {
|
||||
validateEnvironmentVariables: true,
|
||||
@@ -15,11 +16,11 @@ describe('RuntimeSanityValidator', () => {
|
||||
};
|
||||
const sut = new RuntimeSanityValidator(validatorMock);
|
||||
// act
|
||||
sut.bootstrap();
|
||||
await sut.bootstrap();
|
||||
// assert
|
||||
expect(actualOptions).to.deep.equal(expectedOptions);
|
||||
});
|
||||
it('propagates the error if validator fails', () => {
|
||||
it('propagates the error if validator fails', async () => {
|
||||
// arrange
|
||||
const expectedMessage = 'message thrown from validator';
|
||||
const validatorMock = () => {
|
||||
@@ -27,17 +28,17 @@ describe('RuntimeSanityValidator', () => {
|
||||
};
|
||||
const sut = new RuntimeSanityValidator(validatorMock);
|
||||
// act
|
||||
const act = () => sut.bootstrap();
|
||||
const act = async () => { await sut.bootstrap(); };
|
||||
// assert
|
||||
expect(act).to.throw(expectedMessage);
|
||||
await expectThrowsAsync(act, expectedMessage);
|
||||
});
|
||||
it('runs successfully if validator passes', () => {
|
||||
it('runs successfully if validator passes', async () => {
|
||||
// arrange
|
||||
const validatorMock = () => { /* NOOP */ };
|
||||
const sut = new RuntimeSanityValidator(validatorMock);
|
||||
// act
|
||||
const act = () => sut.bootstrap();
|
||||
const act = async () => { await sut.bootstrap(); };
|
||||
// assert
|
||||
expect(act).to.not.throw();
|
||||
await expectDoesNotThrowAsync(act);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import CodeCopyButton from '@/presentation/components/Code/CodeButtons/CodeCopyButton.vue';
|
||||
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
|
||||
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
|
||||
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
|
||||
import { UseCurrentCodeStub } from '@tests/unit/shared/Stubs/UseCurrentCodeStub';
|
||||
|
||||
const COMPONENT_ICON_BUTTON_WRAPPER_NAME = 'IconButton';
|
||||
|
||||
describe('CodeCopyButton', () => {
|
||||
it('copies current code when clicked', async () => {
|
||||
// arrange
|
||||
const expectedCode = 'code to be copied';
|
||||
const clipboard = new ClipboardStub();
|
||||
const wrapper = mountComponent({
|
||||
clipboard,
|
||||
currentCode: expectedCode,
|
||||
});
|
||||
|
||||
// act
|
||||
await wrapper.trigger('click');
|
||||
|
||||
// assert
|
||||
const calls = clipboard.callHistory;
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
const call = calls.find((c) => c.methodName === 'copyText');
|
||||
expect(call).toBeDefined();
|
||||
const [copiedText] = call.args;
|
||||
expect(copiedText).to.equal(expectedCode);
|
||||
});
|
||||
});
|
||||
|
||||
function mountComponent(options?: {
|
||||
clipboard?: Clipboard,
|
||||
currentCode?: string,
|
||||
}) {
|
||||
return shallowMount(CodeCopyButton, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useClipboard as symbol]: () => (
|
||||
options?.clipboard
|
||||
? new UseClipboardStub(options.clipboard)
|
||||
: new UseClipboardStub()
|
||||
).get(),
|
||||
[InjectionKeys.useCurrentCode as symbol]: () => (
|
||||
options.currentCode === undefined
|
||||
? new UseCurrentCodeStub()
|
||||
: new UseCurrentCodeStub().withCurrentCode(options.currentCode)
|
||||
).get(),
|
||||
},
|
||||
stubs: {
|
||||
[COMPONENT_ICON_BUTTON_WRAPPER_NAME]: {
|
||||
name: COMPONENT_ICON_BUTTON_WRAPPER_NAME,
|
||||
template: '<div @click="handleClick()" />',
|
||||
emits: ['click'],
|
||||
setup: (_, { emit }) => ({
|
||||
handleClick: () => emit('click'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import CodeInstruction from '@/presentation/components/Code/CodeButtons/Save/Instructions/CodeInstruction.vue';
|
||||
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clipboard';
|
||||
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
|
||||
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
|
||||
|
||||
const DOM_SELECTOR_CODE_SLOT = 'code';
|
||||
const DOM_SELECTOR_COPY_BUTTON = '.copy-button';
|
||||
const COMPONENT_TOOLTIP_WRAPPER_NAME = 'TooltipWrapper';
|
||||
|
||||
describe('CodeInstruction.vue', () => {
|
||||
it('renders a slot content inside a <code> element', () => {
|
||||
// arrange
|
||||
const expectedSlotContent = 'Example Code';
|
||||
const wrapper = mountComponent({
|
||||
slotContent: expectedSlotContent,
|
||||
});
|
||||
// act
|
||||
const codeSlot = wrapper.find(DOM_SELECTOR_CODE_SLOT);
|
||||
const actualContent = codeSlot.text();
|
||||
// assert
|
||||
expect(actualContent).to.equal(expectedSlotContent);
|
||||
});
|
||||
describe('copy', () => {
|
||||
it('calls copyText when the copy button is clicked', async () => {
|
||||
// arrange
|
||||
const expectedCode = 'Code to be copied';
|
||||
const clipboardStub = new ClipboardStub();
|
||||
const wrapper = mountComponent({
|
||||
clipboard: clipboardStub,
|
||||
});
|
||||
wrapper.vm.codeElement = { textContent: expectedCode } as HTMLElement;
|
||||
// act
|
||||
const copyButton = wrapper.find(DOM_SELECTOR_COPY_BUTTON);
|
||||
await copyButton.trigger('click');
|
||||
// assert
|
||||
const calls = clipboardStub.callHistory;
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
const call = calls.find((c) => c.methodName === 'copyText');
|
||||
expect(call).toBeDefined();
|
||||
const [actualCode] = call.args;
|
||||
expect(actualCode).to.equal(expectedCode);
|
||||
});
|
||||
it('throws an error when codeElement is not found during copy', async () => {
|
||||
// arrange
|
||||
const expectedError = 'Code element could not be found.';
|
||||
const wrapper = mountComponent();
|
||||
wrapper.vm.codeElement = undefined;
|
||||
// act
|
||||
const act = () => wrapper.vm.copyCode();
|
||||
// assert
|
||||
await expectThrowsAsync(act, expectedError);
|
||||
});
|
||||
it('throws an error when codeElement has no textContent during copy', async () => {
|
||||
// arrange
|
||||
const expectedError = 'Code element does not contain any text.';
|
||||
const wrapper = mountComponent();
|
||||
wrapper.vm.codeElement = { textContent: '' } as HTMLElement;
|
||||
// act
|
||||
const act = () => wrapper.vm.copyCode();
|
||||
// assert
|
||||
await expectThrowsAsync(act, expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mountComponent(options?: {
|
||||
readonly clipboard?: Clipboard,
|
||||
readonly slotContent?: string,
|
||||
}) {
|
||||
return shallowMount(CodeInstruction, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useClipboard as symbol]:
|
||||
() => {
|
||||
if (options?.clipboard) {
|
||||
return new UseClipboardStub(options.clipboard).get();
|
||||
}
|
||||
return new UseClipboardStub().get();
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
[COMPONENT_TOOLTIP_WRAPPER_NAME]: {
|
||||
name: COMPONENT_TOOLTIP_WRAPPER_NAME,
|
||||
template: '<slot />',
|
||||
},
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: options?.slotContent ?? 'Stubbed slot content',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
|
||||
import { IInstructionsBuilderData, InstructionsBuilder, InstructionStepBuilderType } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Instructions/InstructionListData';
|
||||
import { IInstructionInfo, IInstructionListStep } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListData';
|
||||
|
||||
describe('InstructionsBuilder', () => {
|
||||
describe('withStep', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/MacOsInstructionsBuilder';
|
||||
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/MacOsInstructionsBuilder';
|
||||
import { runOsSpecificInstructionBuilderTests } from './OsSpecificInstructionBuilderTestRunner';
|
||||
|
||||
describe('MacOsInstructionsBuilder', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
|
||||
|
||||
interface ITestData {
|
||||
readonly factory: () => InstructionsBuilder;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Instructions/InstructionListDataFactory';
|
||||
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory';
|
||||
import { getEnumValues } from '@/application/Common/Enum';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
|
||||
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
|
||||
|
||||
describe('InstructionListDataFactory', () => {
|
||||
const supportedOsList = [OperatingSystem.macOS];
|
||||
@@ -5,11 +5,11 @@ const expectedAttributeName = 'data-interaction-does-not-collapse';
|
||||
|
||||
describe('NonCollapsingDirective', () => {
|
||||
describe('NonCollapsing', () => {
|
||||
it('adds expected attribute to the element when inserted', () => {
|
||||
it('adds expected attribute to the element when mounted', () => {
|
||||
// arrange
|
||||
const element = createElementMock();
|
||||
// act
|
||||
NonCollapsing.inserted(element, undefined, undefined, undefined);
|
||||
NonCollapsing.mounted(element, undefined, undefined, undefined);
|
||||
// assert
|
||||
expect(element.hasAttribute(expectedAttributeName));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.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 { UserFilterMethod, UserFilterStub } from '@tests/unit/shared/Stubs/UserFilterStub';
|
||||
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 { IFilterChangeDetails } from '@/application/Context/State/Filter/Event/IFilterChangeDetails';
|
||||
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_CLOSE_BUTTON = '.search__query__close-button';
|
||||
@@ -123,7 +123,7 @@ describe('TheScriptsView.vue', () => {
|
||||
new FilterResultStub().withQueryAndSomeMatches(),
|
||||
),
|
||||
changeEvents: [
|
||||
FilterChange.forClear(),
|
||||
FilterChangeDetailsStub.forClear(),
|
||||
],
|
||||
expectedComponent: CardList,
|
||||
componentsToDisappear: [ScriptsTree],
|
||||
@@ -132,7 +132,7 @@ describe('TheScriptsView.vue', () => {
|
||||
name: 'tree on search',
|
||||
initialView: ViewType.Cards,
|
||||
changeEvents: [
|
||||
FilterChange.forApply(
|
||||
FilterChangeDetailsStub.forApply(
|
||||
new FilterResultStub().withQueryAndSomeMatches(),
|
||||
),
|
||||
],
|
||||
@@ -143,10 +143,10 @@ describe('TheScriptsView.vue', () => {
|
||||
name: 'return to card after search',
|
||||
initialView: ViewType.Cards,
|
||||
changeEvents: [
|
||||
FilterChange.forApply(
|
||||
FilterChangeDetailsStub.forApply(
|
||||
new FilterResultStub().withQueryAndSomeMatches(),
|
||||
),
|
||||
FilterChange.forClear(),
|
||||
FilterChangeDetailsStub.forClear(),
|
||||
],
|
||||
expectedComponent: CardList,
|
||||
componentsToDisappear: [ScriptsTree],
|
||||
@@ -155,10 +155,10 @@ describe('TheScriptsView.vue', () => {
|
||||
name: 'return to tree after search',
|
||||
initialView: ViewType.Tree,
|
||||
changeEvents: [
|
||||
FilterChange.forApply(
|
||||
FilterChangeDetailsStub.forApply(
|
||||
new FilterResultStub().withQueryAndSomeMatches(),
|
||||
),
|
||||
FilterChange.forClear(),
|
||||
FilterChangeDetailsStub.forClear(),
|
||||
],
|
||||
expectedComponent: ScriptsTree,
|
||||
componentsToDisappear: [CardList],
|
||||
@@ -223,11 +223,11 @@ describe('TheScriptsView.vue', () => {
|
||||
});
|
||||
|
||||
// act
|
||||
filterStub.notifyFilterChange(FilterChange.forApply(
|
||||
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
|
||||
new FilterResultStub().withQueryAndSomeMatches(),
|
||||
));
|
||||
await wrapper.vm.$nextTick();
|
||||
filterStub.notifyFilterChange(FilterChange.forClear());
|
||||
filterStub.notifyFilterChange(FilterChangeDetailsStub.forClear());
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// assert
|
||||
@@ -264,7 +264,7 @@ describe('TheScriptsView.vue', () => {
|
||||
});
|
||||
|
||||
// act
|
||||
filterStub.notifyFilterChange(FilterChange.forApply(
|
||||
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
|
||||
new FilterResultStub().withQueryAndSomeMatches(),
|
||||
));
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -283,7 +283,7 @@ describe('TheScriptsView.vue', () => {
|
||||
const wrapper = mountComponent({
|
||||
useCollectionState: stateStub.get(),
|
||||
});
|
||||
filterStub.notifyFilterChange(FilterChange.forApply(
|
||||
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
|
||||
new FilterResultStub().withQueryAndSomeMatches(),
|
||||
));
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -359,7 +359,7 @@ describe('TheScriptsView.vue', () => {
|
||||
});
|
||||
|
||||
// act
|
||||
filterStub.notifyFilterChange(FilterChange.forApply(
|
||||
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
|
||||
filter,
|
||||
));
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -379,10 +379,10 @@ describe('TheScriptsView.vue', () => {
|
||||
});
|
||||
|
||||
// act
|
||||
filter.notifyFilterChange(FilterChange.forApply(
|
||||
filter.notifyFilterChange(FilterChangeDetailsStub.forApply(
|
||||
new FilterResultStub().withSomeMatches(),
|
||||
));
|
||||
filter.notifyFilterChange(FilterChange.forClear());
|
||||
filter.notifyFilterChange(FilterChangeDetailsStub.forClear());
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// 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
|
||||
.map((component) => wrapper.findComponent(component))
|
||||
.filter((component) => component.exists());
|
||||
@@ -404,15 +404,17 @@ function mountComponent(options?: {
|
||||
readonly viewType?: ViewType,
|
||||
}) {
|
||||
return shallowMount(TheScriptsView, {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
|
||||
[InjectionKeys.useApplication as symbol]:
|
||||
new UseApplicationStub().get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
|
||||
[InjectionKeys.useApplication as symbol]:
|
||||
new UseApplicationStub().get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
propsData: {
|
||||
props: {
|
||||
currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
Wrapper, shallowMount,
|
||||
VueWrapper, shallowMount,
|
||||
mount,
|
||||
} from '@vue/test-utils';
|
||||
import { nextTick, defineComponent } from 'vue';
|
||||
@@ -92,11 +92,11 @@ describe('ToggleSwitch.vue', () => {
|
||||
const { checkboxWrapper } = getCheckboxElement(wrapper);
|
||||
|
||||
// act
|
||||
await checkboxWrapper.setChecked(newCheckValue);
|
||||
await checkboxWrapper.setValue(newCheckValue);
|
||||
await nextTick();
|
||||
|
||||
// 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);
|
||||
|
||||
// act
|
||||
await checkboxWrapper.setChecked(value);
|
||||
await checkboxWrapper.setValue(value);
|
||||
await nextTick();
|
||||
|
||||
// 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();
|
||||
|
||||
// assert
|
||||
expect(switchWrapper.exists());
|
||||
const receivedEvents = parentWrapper.emitted(parentClickEventName);
|
||||
expect(receivedEvents).to.equal(undefined);
|
||||
});
|
||||
@@ -161,14 +160,13 @@ describe('ToggleSwitch.vue', () => {
|
||||
await nextTick();
|
||||
|
||||
// assert
|
||||
expect(switchWrapper.exists());
|
||||
const receivedEvents = parentWrapper.emitted(parentClickEventName);
|
||||
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 checkboxElement = checkboxWrapper.element as HTMLInputElement;
|
||||
return {
|
||||
@@ -184,9 +182,9 @@ function mountComponent(options?: {
|
||||
readonly stopClickPropagation?: boolean,
|
||||
}
|
||||
}) {
|
||||
const wrapper = shallowMount(ToggleSwitch as unknown, {
|
||||
propsData: {
|
||||
value: options?.properties?.modelValue,
|
||||
const wrapper = shallowMount(ToggleSwitch, {
|
||||
props: {
|
||||
modelValue: options?.properties?.modelValue,
|
||||
label: options?.properties?.label ?? 'test-label',
|
||||
stopClickPropagation: options?.properties?.stopClickPropagation,
|
||||
},
|
||||
@@ -225,9 +223,11 @@ function mountToggleSwitchParent(options?: {
|
||||
},
|
||||
});
|
||||
const wrapper = mount(
|
||||
parentComponent as unknown,
|
||||
parentComponent,
|
||||
{
|
||||
stubs: { ToggleSwitch: false },
|
||||
global: {
|
||||
stubs: { ToggleSwitch: false },
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate,
|
||||
TreeViewFilterAction, TreeViewFilterPredicate,
|
||||
createFilterRemovedEvent, createFilterTriggeredEvent,
|
||||
} from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent';
|
||||
|
||||
@@ -47,19 +47,6 @@ describe('TreeViewFilterEvent', () => {
|
||||
// expect
|
||||
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', () => {
|
||||
@@ -79,19 +66,6 @@ describe('TreeViewFilterEvent', () => {
|
||||
// assert
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('useKeyboardInteractionState', () => {
|
||||
const { listeners, windowStub } = createWindowStub();
|
||||
// act
|
||||
const { wrapper } = mountWrapperComponent(windowStub);
|
||||
wrapper.destroy();
|
||||
wrapper.unmount();
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(listeners.keydown).to.have.lengthOf(0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ref, defineComponent, WatchSource, nextTick,
|
||||
shallowRef, defineComponent, WatchSource, nextTick,
|
||||
} from 'vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
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', () => {
|
||||
// arrange
|
||||
const expectedState = new TreeNodeStateDescriptorStub();
|
||||
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
|
||||
nodeWatcher.value = new TreeNodeStub()
|
||||
.withState(new TreeNodeStateAccessStub().withCurrent(expectedState));
|
||||
// act
|
||||
@@ -27,7 +27,7 @@ describe('useNodeState', () => {
|
||||
|
||||
it('should not set state on immediate invocation if node is undefined', () => {
|
||||
// arrange
|
||||
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
|
||||
// act
|
||||
const { returnObject } = mountWrapperComponent(nodeWatcher);
|
||||
// assert
|
||||
@@ -37,7 +37,7 @@ describe('useNodeState', () => {
|
||||
it('should update state when nodeWatcher changes', async () => {
|
||||
// arrange
|
||||
const expectedNewState = new TreeNodeStateDescriptorStub();
|
||||
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
|
||||
const { returnObject } = mountWrapperComponent(nodeWatcher);
|
||||
// act
|
||||
nodeWatcher.value = new TreeNodeStub()
|
||||
@@ -49,7 +49,7 @@ describe('useNodeState', () => {
|
||||
|
||||
it('should update state when node state changes', () => {
|
||||
// arrange
|
||||
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
|
||||
const nodeWatcher = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
|
||||
const stateAccessStub = new TreeNodeStateAccessStub();
|
||||
const expectedChangedState = new TreeNodeStateDescriptorStub();
|
||||
nodeWatcher.value = new TreeNodeStub()
|
||||
@@ -77,9 +77,11 @@ function mountWrapperComponent(nodeWatcher: WatchSource<ReadOnlyTreeNode | undef
|
||||
template: '<div></div>',
|
||||
}),
|
||||
{
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ref, defineComponent, WatchSource, nextTick,
|
||||
shallowRef, defineComponent, WatchSource, nextTick,
|
||||
} from 'vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { TreeRoot } from '@/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/TreeRoot';
|
||||
@@ -15,7 +15,7 @@ describe('useCurrentTreeNodes', () => {
|
||||
it('should set nodes on immediate invocation', () => {
|
||||
// arrange
|
||||
const expectedNodes = new QueryableNodesStub();
|
||||
const treeWatcher = ref<TreeRoot>(new TreeRootStub().withCollection(
|
||||
const treeWatcher = shallowRef<TreeRoot>(new TreeRootStub().withCollection(
|
||||
new TreeNodeCollectionStub().withNodes(expectedNodes),
|
||||
));
|
||||
// act
|
||||
@@ -27,7 +27,7 @@ describe('useCurrentTreeNodes', () => {
|
||||
it('should update nodes when treeWatcher changes', async () => {
|
||||
// arrange
|
||||
const initialNodes = new QueryableNodesStub();
|
||||
const treeWatcher = ref(
|
||||
const treeWatcher = shallowRef(
|
||||
new TreeRootStub().withCollection(new TreeNodeCollectionStub().withNodes(initialNodes)),
|
||||
);
|
||||
const { returnObject } = mountWrapperComponent(treeWatcher);
|
||||
@@ -45,7 +45,7 @@ describe('useCurrentTreeNodes', () => {
|
||||
// arrange
|
||||
const initialNodes = new QueryableNodesStub();
|
||||
const treeCollectionStub = new TreeNodeCollectionStub().withNodes(initialNodes);
|
||||
const treeWatcher = ref(new TreeRootStub().withCollection(treeCollectionStub));
|
||||
const treeWatcher = shallowRef(new TreeRootStub().withCollection(treeCollectionStub));
|
||||
|
||||
const { returnObject } = mountWrapperComponent(treeWatcher);
|
||||
|
||||
@@ -68,9 +68,11 @@ function mountWrapperComponent(treeWatcher: WatchSource<TreeRoot | undefined>) {
|
||||
template: '<div></div>',
|
||||
}),
|
||||
{
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -334,9 +334,11 @@ class UseNodeStateChangeAggregatorBuilder {
|
||||
template: '<div></div>',
|
||||
}),
|
||||
{
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => this.events.get(),
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => this.events.get(),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -170,8 +170,10 @@ function mountWrapperComponent() {
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { useSelectedScriptNodeIds } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
|
||||
@@ -8,9 +9,10 @@ import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCo
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
|
||||
describe('useSelectedScriptNodeIds', () => {
|
||||
it('returns empty array when no scripts are selected', () => {
|
||||
it('returns an empty array when no scripts are selected', () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
useStateStub.withState(new CategoryCollectionStateStub().withSelectedScripts([]));
|
||||
@@ -19,38 +21,213 @@ describe('useSelectedScriptNodeIds', () => {
|
||||
// assert
|
||||
expect(actualIds).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('returns correct node IDs for selected scripts', () => {
|
||||
it('initially registers the unsubscribe callback', () => {
|
||||
// arrange
|
||||
const selectedScripts = [
|
||||
new SelectedScriptStub('id-1'),
|
||||
new SelectedScriptStub('id-2'),
|
||||
];
|
||||
const parsedNodeIds = new Map<IScript, string>([
|
||||
[selectedScripts[0].script, 'expected-id-1'],
|
||||
[selectedScripts[1].script, 'expected-id-1'],
|
||||
]);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent({
|
||||
scriptNodeIdParser: (script) => parsedNodeIds.get(script),
|
||||
});
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelectedScripts(selectedScripts),
|
||||
immediateOnly: true,
|
||||
});
|
||||
const eventsStub = new UseAutoUnsubscribedEventsStub();
|
||||
// act
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
mountWrapperComponent({
|
||||
useAutoUnsubscribedEvents: eventsStub,
|
||||
});
|
||||
// assert
|
||||
const expectedNodeIds = [...parsedNodeIds.values()];
|
||||
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
|
||||
expect(actualIds).to.include.members(expectedNodeIds);
|
||||
const calls = eventsStub.events.callHistory;
|
||||
expect(eventsStub.events.callHistory).has.lengthOf(1);
|
||||
const call = calls.find((c) => c.methodName === 'unsubscribeAllAndRegister');
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
describe('returns correct node IDs for selected scripts', () => {
|
||||
it('immediately', () => {
|
||||
// arrange
|
||||
const selectedScripts = [
|
||||
new SelectedScriptStub('id-1'),
|
||||
new SelectedScriptStub('id-2'),
|
||||
];
|
||||
const parsedNodeIds = new Map<IScript, string>([
|
||||
[selectedScripts[0].script, 'expected-id-1'],
|
||||
[selectedScripts[1].script, 'expected-id-2'],
|
||||
]);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent({
|
||||
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
|
||||
});
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelectedScripts(selectedScripts),
|
||||
immediateOnly: true,
|
||||
});
|
||||
// act
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
// assert
|
||||
const expectedNodeIds = [...parsedNodeIds.values()];
|
||||
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
|
||||
expect(actualIds).to.include.members(expectedNodeIds);
|
||||
});
|
||||
it('when the collection state changes', () => {
|
||||
// arrange
|
||||
const initialScripts = [];
|
||||
const changedScripts = [
|
||||
new SelectedScriptStub('id-1'),
|
||||
new SelectedScriptStub('id-2'),
|
||||
];
|
||||
const parsedNodeIds = new Map<IScript, string>([
|
||||
[changedScripts[0].script, 'expected-id-1'],
|
||||
[changedScripts[1].script, 'expected-id-2'],
|
||||
]);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent({
|
||||
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
|
||||
});
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelectedScripts(initialScripts),
|
||||
immediateOnly: true,
|
||||
});
|
||||
// act
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelectedScripts(changedScripts),
|
||||
immediateOnly: false,
|
||||
});
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
// assert
|
||||
const expectedNodeIds = [...parsedNodeIds.values()];
|
||||
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
|
||||
expect(actualIds).to.include.members(expectedNodeIds);
|
||||
});
|
||||
it('when the selection state changes', () => {
|
||||
// arrange
|
||||
const initialScripts = [];
|
||||
const changedScripts = [
|
||||
new SelectedScriptStub('id-1'),
|
||||
new SelectedScriptStub('id-2'),
|
||||
];
|
||||
const parsedNodeIds = new Map<IScript, string>([
|
||||
[changedScripts[0].script, 'expected-id-1'],
|
||||
[changedScripts[1].script, 'expected-id-2'],
|
||||
]);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent({
|
||||
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
|
||||
});
|
||||
const userSelection = new UserSelectionStub([])
|
||||
.withSelectedScripts(initialScripts);
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection),
|
||||
immediateOnly: true,
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent(changedScripts);
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
// assert
|
||||
const expectedNodeIds = [...parsedNodeIds.values()];
|
||||
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
|
||||
expect(actualIds).to.include.members(expectedNodeIds);
|
||||
});
|
||||
});
|
||||
describe('reactivity to state changes', () => {
|
||||
describe('when the collection state changes', () => {
|
||||
it('with new array references', async () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub(),
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
it('with the same array reference', async () => {
|
||||
// arrange
|
||||
const sharedSelectedScriptsReference = [];
|
||||
const initialCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
const changedCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: initialCollectionState,
|
||||
immediateOnly: true,
|
||||
});
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: changedCollectionState,
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
});
|
||||
describe('when the selection state changes', () => {
|
||||
it('with new array references', async () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
const userSelection = new UserSelectionStub([])
|
||||
.withSelectedScripts([]);
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection),
|
||||
immediateOnly: true,
|
||||
});
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent([]);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
it('with the same array reference', async () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
const sharedSelectedScriptsReference = [];
|
||||
const userSelection = new UserSelectionStub([])
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection),
|
||||
immediateOnly: true,
|
||||
});
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
|
||||
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ScriptNodeIdParser = typeof getScriptNodeId;
|
||||
|
||||
function createNodeIdParserFromMap(scriptToIdMap: Map<IScript, string>): ScriptNodeIdParser {
|
||||
return (script) => {
|
||||
const expectedId = scriptToIdMap.get(script);
|
||||
if (!expectedId) {
|
||||
throw new Error(`No mapped ID for script: ${JSON.stringify(script)}`);
|
||||
}
|
||||
return expectedId;
|
||||
};
|
||||
}
|
||||
|
||||
function mountWrapperComponent(scenario?: {
|
||||
readonly scriptNodeIdParser?: typeof getScriptNodeId,
|
||||
readonly scriptNodeIdParser?: ScriptNodeIdParser,
|
||||
readonly useAutoUnsubscribedEvents?: UseAutoUnsubscribedEventsStub,
|
||||
}) {
|
||||
const useStateStub = new UseCollectionStateStub();
|
||||
const nodeIdParser: typeof getScriptNodeId = scenario?.scriptNodeIdParser
|
||||
const nodeIdParser: ScriptNodeIdParser = scenario?.scriptNodeIdParser
|
||||
?? ((script) => script.id);
|
||||
let returnObject: ReturnType<typeof useSelectedScriptNodeIds>;
|
||||
|
||||
@@ -60,11 +237,13 @@ function mountWrapperComponent(scenario?: {
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
() => useStateStub.get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => new UseAutoUnsubscribedEventsStub().get(),
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]:
|
||||
() => useStateStub.get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
|
||||
() => (scenario?.useAutoUnsubscribedEvents ?? new UseAutoUnsubscribedEventsStub()).get(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -104,8 +104,10 @@ function mountWrapperComponent(categoryIdWatcher: WatchSource<number | undefined
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState as symbol]: () => useStateStub.get(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { BrowserClipboard, NavigatorClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
|
||||
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
|
||||
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
|
||||
|
||||
describe('BrowserClipboard', () => {
|
||||
describe('writeText', () => {
|
||||
it('calls navigator clipboard with the correct text', async () => {
|
||||
// arrange
|
||||
const expectedText = 'test text';
|
||||
const navigatorClipboard = new NavigatorClipboardStub();
|
||||
const clipboard = new BrowserClipboard(navigatorClipboard);
|
||||
// act
|
||||
await clipboard.copyText(expectedText);
|
||||
// assert
|
||||
const calls = navigatorClipboard.callHistory;
|
||||
expect(calls).to.have.lengthOf(1);
|
||||
const call = calls.find((c) => c.methodName === 'writeText');
|
||||
expect(call).toBeDefined();
|
||||
const [actualText] = call.args;
|
||||
expect(actualText).to.equal(expectedText);
|
||||
});
|
||||
it('throws when navigator clipboard fails', async () => {
|
||||
// arrange
|
||||
const expectedError = 'internalError';
|
||||
const navigatorClipboard = new NavigatorClipboardStub();
|
||||
navigatorClipboard.writeText = () => {
|
||||
throw new Error(expectedError);
|
||||
};
|
||||
const clipboard = new BrowserClipboard(navigatorClipboard);
|
||||
// act
|
||||
const act = () => clipboard.copyText('unimportant-text');
|
||||
// assert
|
||||
await expectThrowsAsync(act, expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class NavigatorClipboardStub
|
||||
extends StubWithObservableMethodCalls<NavigatorClipboard>
|
||||
implements NavigatorClipboard {
|
||||
writeText(data: string): Promise<void> {
|
||||
this.registerMethodCall({
|
||||
methodName: 'writeText',
|
||||
args: [data],
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
read(): Promise<ClipboardItems> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
readText(): Promise<string> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
write(): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
addEventListener(): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
dispatchEvent(): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
removeEventListener(): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
|
||||
import { BrowserClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/BrowserClipboard';
|
||||
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
|
||||
import { FunctionKeys } from '@/TypeHelpers';
|
||||
|
||||
describe('useClipboard', () => {
|
||||
it(`returns an instance of ${BrowserClipboard.name}`, () => {
|
||||
// arrange
|
||||
const expectedType = BrowserClipboard;
|
||||
// act
|
||||
const clipboard = useClipboard();
|
||||
// assert
|
||||
expect(clipboard).to.be.instanceOf(expectedType);
|
||||
});
|
||||
it('does not create a new instance if one is provided', () => {
|
||||
// arrange
|
||||
const expectedClipboard = new ClipboardStub();
|
||||
// act
|
||||
const actualClipboard = useClipboard(expectedClipboard);
|
||||
// assert
|
||||
expect(actualClipboard).to.equal(expectedClipboard);
|
||||
});
|
||||
describe('supports object destructuring', () => {
|
||||
type ClipboardFunction = FunctionKeys<ReturnType<typeof useClipboard>>;
|
||||
const testScenarios: {
|
||||
readonly [FunctionName in ClipboardFunction]:
|
||||
Parameters<ReturnType<typeof useClipboard>[FunctionName]>;
|
||||
} = {
|
||||
copyText: ['text-arg'],
|
||||
};
|
||||
Object.entries(testScenarios).forEach(([functionName, testFunctionArgs]) => {
|
||||
describe(functionName, () => {
|
||||
it('binds the method to the instance', () => {
|
||||
// arrange
|
||||
const expectedArgs = testFunctionArgs;
|
||||
const clipboardStub = new ClipboardStub();
|
||||
// act
|
||||
const clipboard = useClipboard(clipboardStub);
|
||||
const { [functionName as ClipboardFunction]: testFunction } = clipboard;
|
||||
// assert
|
||||
testFunction(...expectedArgs);
|
||||
const call = clipboardStub.callHistory.find((c) => c.methodName === functionName);
|
||||
expect(call).toBeDefined();
|
||||
expect(call.args).to.deep.equal(expectedArgs);
|
||||
});
|
||||
it('ensures method retains the clipboard instance context', () => {
|
||||
// arrange
|
||||
const clipboardStub = new ClipboardStub();
|
||||
const expectedThisContext = clipboardStub;
|
||||
let actualThisContext: typeof expectedThisContext | undefined;
|
||||
// eslint-disable-next-line func-names
|
||||
clipboardStub[functionName] = function () {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
actualThisContext = this;
|
||||
};
|
||||
// act
|
||||
const clipboard = useClipboard(clipboardStub);
|
||||
const { [functionName as ClipboardFunction]: testFunction } = clipboard;
|
||||
// assert
|
||||
testFunction(...testFunctionArgs);
|
||||
expect(expectedThisContext).to.equal(actualThisContext);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,7 @@ describe('UseAutoUnsubscribedEvents', () => {
|
||||
});
|
||||
});
|
||||
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
|
||||
const events = new EventSubscriptionCollectionStub();
|
||||
const expectedCall: FunctionKeys<EventSubscriptionCollection> = 'unsubscribeAll';
|
||||
@@ -58,7 +58,7 @@ describe('UseAutoUnsubscribedEvents', () => {
|
||||
events.callHistory.length = 0;
|
||||
|
||||
// act
|
||||
stubComponent.destroy();
|
||||
stubComponent.unmount();
|
||||
|
||||
// assert
|
||||
expect(events.callHistory).to.have.lengthOf(1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user