Compare commits

..

2 Commits

Author SHA1 Message Date
undergroundwires
493fb1ec16 mac: add new scripts and category for services 2023-10-19 01:21:03 +02:00
undergroundwires
b167a69976 Fix YAML error for site release in CI/CD
Fix the syntax error in the GitHub action script that was caused by
improper multi-line YAML notation. This correction ensures the action
can successfully parse and execute.
2023-10-19 00:45:23 +02:00
122 changed files with 2846 additions and 4980 deletions

View File

@@ -1,30 +1,5 @@
# 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)

View File

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

View File

@@ -122,7 +122,7 @@
## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.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).
- 🖥️ **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).
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
@@ -153,21 +153,12 @@ 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.
- 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):
- Using [Scoop](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) package manager on Windows:
```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.
```powershell
scoop bucket add extras
scoop install privacy.sexy
```
## Development

View File

@@ -2,142 +2,79 @@
## Benefits of templating
- **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.
- 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.
## Expressions
**Syntax:**
- 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.
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 }}
```
```go
{{ with $condition }}
echo {{ $text }}
{{ end }}
```
### Parameter substitution
Parameter substitution dynamically replaces variable references with their corresponding values in the script.
**Example function:**
A simple function example:
```yaml
name: DisplayTextFunction
function: EchoArgument
parameters:
- name: 'text'
code: echo {{ $text }}
- name: 'argument'
code: Hello {{ $argument }} !
```
Invoking `DisplayTextFunction` with `text` set to `"Hello, world!"` would result in `echo "Hello, World!"`.
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 }} !
```
### with
The `with` expression enables conditional rendering and provides a context variable for simpler code.
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..
**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:
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:
```go
{{ with $parameterName }}Parameter value is {{ . }} here {{ end }}
```
**Multiline text:**
It supports multiline text inside the block. You can write something like:
It supports multiline text inside the block. You can have something like:
```go
{{ with $argument }}
@@ -146,9 +83,7 @@ It supports multiline text inside the block. You can write something like:
{{ end }}
```
**Inner expressions:**
You can also embed other expressions inside its block, such as [parameter substitution](#parameter-substitution):
You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution):
```go
{{ with $condition }}
@@ -156,44 +91,32 @@ You can also embed other expressions inside its block, such as [parameter substi
{{ end }}
```
This also includes nesting `with` statements:
💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
```go
{{ with $condition1 }}
Value of $condition1: {{ . }}
{{ with $condition2 }}
Value of $condition2: {{ . }}
Example:
```yaml
function: FunctionThatOutputsConditionally
parameters:
- name: 'argument'
optional: true
code: |-
{{ with $argument }}
Value is: {{ . }}
{{ end }}
{{ end }}
```
### Pipes
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 (`"`).
- 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 }}`

View File

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

1183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
{
"name": "privacy.sexy",
"version": "0.12.6",
"version": "0.12.5",
"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",
@@ -42,7 +43,7 @@
"electron-updater": "^6.1.4",
"file-saver": "^2.0.5",
"markdown-it": "^13.0.2",
"vue": "^3.3.7"
"vue": "^2.7.14"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4",
@@ -52,17 +53,17 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-legacy": "^4.1.1",
"@vitejs/plugin-vue": "^4.4.0",
"@vitejs/plugin-vue2": "^2.2.0",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^2.4.1",
"@vue/test-utils": "^1.3.6",
"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.28",
"electron-vite": "^1.0.27",
"eslint": "^8.51.0",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.17.0",
@@ -97,11 +98,5 @@
"repository": {
"type": "git",
"url": "https://github.com/undergroundwires/privacy.sexy.git"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
},
"//optionalDependencies": {
"dmg-license": "Required by `electron-builder` for DMG builds on macOS, https://github.com/electron-userland/electron-builder/issues/6489, https://github.com/electron-userland/electron-builder/issues/6520"
}
}

View File

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

View File

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

View File

@@ -1,222 +1,59 @@
// 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 { IExpression } from '../Expression/IExpression';
import { ExpressionPosition } from '../Expression/ExpressionPosition';
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class WithParser implements IExpressionParser {
public findExpressions(code: string): IExpression[] {
if (!code) {
throw new Error('missing code');
}
return parseWithExpressions(code);
}
}
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();
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);
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1];
const scopeText = match[2];
return {
parameters,
position,
evaluate: (context) => {
const argumentValue = context.args.hasArgument(this.parameterName)
? context.args.getArgument(this.parameterName).argumentValue
parameters: [new FunctionParameter(parameterName, true)],
evaluator: (context) => {
const argumentValue = context.args.hasArgument(parameterName)
? context.args.getArgument(parameterName).argumentValue
: undefined;
if (!argumentValue) {
return '';
}
const substitutedScope = this.substituteContextVariables(scope, (pipeline) => {
return replaceEachScopeSubstitution(scopeText, (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;
}
}
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()
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
// {{ . | pipeName }}
.expectExpressionStart()
.expectCharacters('.')
.expectOptionalWhitespaces()
.captureOptionalPipeline() // First capture: pipeline
.matchPipeline() // First match: pipeline
.expectExpressionEnd()
.buildRegExp();
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();
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);
});
}

View File

@@ -707,9 +707,15 @@ actions:
-
category: Clear Firefox history
docs: |-
This category encompasses a series of scripts aimed at helping users manage and delete their browsing history and related data in Mozilla Firefox.
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].
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.
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"
children:
-
name: Clear Firefox cache
@@ -749,13 +755,9 @@ actions:
# Snap installation
rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/*
-
function: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: crashes/*
-
function: DeleteFilesFromFirefoxProfiles
parameters:
pathGlob: crashes/events/*
path: crashes/
-
name: Clear Firefox cookies
docs: |-
@@ -763,37 +765,41 @@ actions:
[1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
call:
function: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: cookies.sqlite
path: 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: |-
This script targets the Firefox browsing history, including URLs, downloads, bookmarks, and site visits, by deleting specific database entries.
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].
Firefox stores various user data in a file named `places.sqlite`. This file includes:
**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].
- 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]
**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].
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.
**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].
[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"
@@ -804,21 +810,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: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: downloads.rdf
path: downloads.rdf
-
function: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: downloads.sqlite
path: downloads.sqlite
-
function: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: places.sqlite
path: places.sqlite
-
function: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: favicons.sqlite
path: favicons.sqlite
-
name: Clear Firefox logins
docs: |-
@@ -831,17 +837,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: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: logins.json
path: logins.json
-
function: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: logins-backup.json
path: logins-backup.json
-
function: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: signons.sqlite
path: signons.sqlite
-
name: Clear Firefox autocomplete history
docs: |-
@@ -850,9 +856,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: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: formhistory.sqlite
path: formhistory.sqlite
-
name: Clear Firefox "Multi-Account Containers" data
docs: |-
@@ -860,9 +866,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: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: containers.json
path: containers.json
-
name: Clear Firefox open tabs and windows data
docs: |-
@@ -872,9 +878,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: DeleteFilesFromFirefoxProfiles
function: DeleteFromFirefoxProfiles
parameters:
pathGlob: sessionstore.jsonlz4
path: sessionstore.jsonlz4
-
category: Clear system and kernel usage data
docs: |-
@@ -2905,8 +2911,7 @@ actions:
function: AddFirefoxPrefs
parameters:
prefName: toolkit.telemetry.log.level
jsonValue: >-
"Fatal"
jsonValue: 'Fatal'
-
name: Disable Firefox telemetry log output
recommend: standard
@@ -2919,8 +2924,7 @@ actions:
function: AddFirefoxPrefs
parameters:
prefName: toolkit.telemetry.log.dump
jsonValue: >-
"Fatal"
jsonValue: 'Fatal'
-
name: Clear Firefox telemetry user ID
recommend: standard
@@ -3487,66 +3491,16 @@ functions:
>&2 echo "Failed, $service does not exist."
fi
-
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.
name: DeleteFromFirefoxProfiles
parameters:
- name: codeComment
optional: true
- name: revertCodeComment
optional: true
call:
function: RunInlineCode
parameters:
code: '{{ with $codeComment }}# {{ . }}{{ end }}'
revertCode: '{{ with $revertCodeComment }}# {{ . }}{{ end }}'
-
name: DeleteFiles
parameters:
- name: fileGlob
call:
-
function: Comment
parameters:
codeComment: >-
Delete files matching pattern: "{{ $fileGlob }}"
-
function: RunPython3Code
parameters:
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: 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: CleanTableFromFirefoxProfileDatabase
parameters:

View File

@@ -1238,6 +1238,376 @@ actions:
sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate 'CriticalUpdateInstall' -bool true
# Trigger background check with normal scan (critical updates only)
sudo softwareupdate --background-critical
-
category: Disable OS services
children:
# Get active services : launchctl list | grep -v "\-\t0"
# Find a service : sudo grep -lR [service] /System/Library/Launch* /Library/Launch* ~/Library/LaunchAgents
# Locate a service : pgrep -fl [service]
# TODO: https://gist.github.com/ecompayment/b1054421eb90f296bbca226683c7ff7e
-
category: Disable continuously data-collecting services by default
children:
-
name: Disable diagnostics and usage data sender
recommend: standard
docs: https://apple.stackexchange.com/questions/66119/disable-submitdiaginfo
call:
function: DisableService
parameters:
name: com.apple.SubmitDiagInfo
type: LaunchDaemons
-
name: Disable diagnostics and usage data sender
recommend: standard
call:
-
function: DisableService
parameters:
name: com.apple.rtcreportingd.plist
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/rtcreportingd
-
name: Disable Family Circle Daemon for Family Sharing
docs: https://support.apple.com/en-us/HT201060
recommend: standard
# Connects to setup.icloud.com HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.familycircled
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/FamilyCircle.framework/Versions/A/Resources/familycircled
-
name: Disable home sharing
docs: https://discussions.apple.com/thread/7434075?answerId=29677460022#29677460022
# Connects to apps.mzstatic.com and init.itunes.apple.com HTTPS (TCP 443 )
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.itunescloudd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/rtcreportingd # TODO: SIP required?
-
name: Disable CommerceKit handling purchases for Apple products
# the Mac App Store, iTunes store, and Book Store
# Connects to init.itunes.apple.com and xp.apple.com HTTPS (TCP 443 )
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.commerce.plist
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/commerce
-
category: Disable Siri services # TODO: merge with other assistantd script
children:
-
name: Disable Siri dictation service sending voice data
recommend: strict
docs: https://apple.stackexchange.com/questions/57514/what-is-assistantd
# Connects to guzzoni.apple.com HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.assistantd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Support/assistantd
-
name: Disable Siri assistant service
recommend: strict
docs: https://www.howtogeek.com/354897/what-are-assistant_service-and-assistantd-and-why-are-they-running-on-my-mac/
# Connects to radio.itunes.apple.com HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.assistant_service.plist
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Support/assistant_service
-
category: Disable Messages services
docs: https://blog.quarkslab.com/imessage-privacy.html
children:
-
name: Disable Apple Push Service Daemon used for Notification Center and Messages
# Connects to *-courier.push.apple.com (where * is a number) using HTTPS (TCP 443) and apple-push (TCP 5223)
call:
-
function: DisableService
parameters:
name: com.apple.apsd
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/ApplePushService.framework/apsd
-
name: Disable iMessage Agent in Messages app
# Used for e.g. FaceTime invitations
docs:
- https://apple.stackexchange.com/questions/86814/firewall-settings-with-imagent
- https://blog.quarkslab.com/imessage-privacy.html
# Connects to using HTTPS (TCP 443) and apple-push (TCP 5223)
call:
-
function: DisableService
parameters:
name: com.apple.imagent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/IMCore.framework/imagent.app/Contents/MacOS/imagent
-
name: Disable Address Book Source Sync (breaks Contacts data sync)
# Synchronizes data data for the “Contacts” app with iCloud, CardDAV, and Exchange servers
docs: https://apple.stackexchange.com/questions/219774/how-to-disable-addressbooksourcesync-in-el-capitan
# Connects to p25-contacts.icloud.com using HTTPS (TCP 443) and apple-push (TCP 5223)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.AddressBook.SourceSync
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/Frameworks/AddressBook.framework/Versions/A/Helpers/AddressBookSourceSync.app/Contents/MacOS/AddressBookSourceSync
-
name: Disable usage tracking agent
recommend: strict
docs: https://www.unix.com/man-page/mojave/8/USAGETRACKINGAGENT/
# Connects to itunes.apple.com using HTTPS 443 (TCP)
call:
-
function: DisableService
parameters:
name: com.apple.UsageTrackingAgent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/UsageTracking.framework/Versions/A/UsageTrackingAgent
-
name: Disable AMPLibraryAgent for Apple Music
# Connects to buy.itunes.apple.com, init.itunes.apple.com, play.itunes.apple.com, xp.apple.com using HTTPS 443 (TCP)
call:
-
function: DisableService
parameters:
name: com.apple.AMPLibraryAgent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: System/Library/PrivateFrameworks/AMPLibrary.framework/Versions/A/Support/AMPLibraryAgent
-
category: Disable location services
children:
-
name: Disable Maps push daemon
docs:
- https://www.unix.com/man-page/mojave/8/MAPSPUSHD/
- https://discussions.apple.com/thread/7025815
call:
function: DisableService
parameters:
name: com.apple.Maps.pushdaemon
type: LaunchAgents
-
name: Disable Geo Daemon / geolocation daemon used to show maps by apps e.g. Maps
# Connects to Apple servers for loading map data on behalf of other apps and for resolving geographical coordinates to readable addresses.
# Connects to gspe*-ssl.ls.apple.com (where * is a number from 1 to 100 ), sp-ssl.ls.apple.com, configuration.ls.apple.com using HTTPS 443 (TCP)
call:
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
parameters:
filePath: /System/Library/PrivateFrameworks/GeoServices.framework/Versions/A/XPCServices/com.apple.geod.xpc/Contents/MacOS/com.apple.geod
-
name: Disable Location-Based Suggestions for Siri, Spotlight and other places
# Used for suggestions in Spotlight, Messages, Lookup, Safari, Siri, and other place
# Connects to api-glb-euc1b.smoot.apple.com, api.smoot.apple.com using HTTPS 443 (TCP)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.parsecd
type: LaunchAgents
-
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
parameters:
filePath: /System/Library/PrivateFrameworks/CoreParsec.framework/parsecd
-
category: Disable iCloud services
children:
-
name: Disable iCloud notification agent
recommend: strict
call:
function: DisableService
parameters:
name: com.apple.iCloudNotificationAgent
type: LaunchAgents
-
name: Disable Sync Defaults Daemon
# Syncs user preferences or other configuration related data via iCloud
docs: https://www.unix.com/man-page/mojave/8/syncdefaultsd
# Connects to keyvalueservice.icloud.com and p*-keyvalueservice.icloud.com (where * is a number) using HTTPS 443 (TCP)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.syncdefaultsd
type: LaunchAgents
-
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
parameters:
filePath: /System/Library/PrivateFrameworks/SyncedDefaults.framework/Support/syncdefaultsd
-
name: Disable Reminder Daemon that synchronizes the reminder list in "Reminders" with iCloud
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.remindd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/remindd #TODO: Mb don't require SIP
-
name: Disable Cloud Daemon used for iCloud syncing
# Connects to gateway.icloud.com, metrics.icloud.com using HTTPS 443 (TCP)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.cloudd
type: LaunchAgents
-
function: DisableService
parameters:
name: com.apple.cloudd
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd
-
name: Disable Help Daemon (breaks HelpViewer feature)
recommend: strict
docs: https://discussions.apple.com/thread/3930621
# Connects to cds.apple.com, help.apple.com using HTTPS (TCP 443)
call:
-
function: DisableService
parameters:
name: com.apple.helpd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/HelpData.framework/Versions/A/Resources/helpd
-
name: Disable Rapport Daemon for communication between Apple devices
# Rapport Daemon is a macOS system process that enables Phone Call Handoff and other communication features between Apple devices.
# Connects to init.ess.apple.com using HTTPS (TCP 443)
docs: https://apple.stackexchange.com/questions/308294/what-is-rapportd-and-why-does-it-want-incoming-network-connections
call:
-
function: DisableService
parameters:
name: com.apple.rapportd-user
type: LaunchAgents
-
function: DisableService
parameters:
name: com.apple.rapportd
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/rapportd #TODO: No SIP required?
-
name: Disable App Tracking Transparency framework
docs:
- https://apple.stackexchange.com/questions/409349/what-is-the-transparencyd-daemon-for
- https://developer.apple.com/documentation/apptrackingtransparency
# Connects to server kt-prod.apple.com using HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.transparencyd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/transparencyd #TODO: No need for SIP?
-
category: Disable Calendar Agent that sync Calender App to iCloud and other servers
call:
-
function: DisableService
parameters:
name: com.apple.CalendarAgent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/CalendarAgent.framework/Executables/CalendarAgent
-
name: Disable advertising services daemon
recommend: strict
docs: https://www.unix.com/man-page/mojave/8/adservicesd
call:
function: DisableService
parameters:
name: com.apple.ap.adservicesd
type: LaunchAgents
-
name: Disable NetBIOS interactions (might break Microsoft services)
# Mostly used for mostly SMB network volumes
docs: https://www.manpagez.com/man/8/netbiosd/
call:
-
function: DisableService
parameters:
name: com.apple.netbiosd
type: LaunchDaemons
function: RenameSystemFile
parameters:
filePath: /usr/sbin/netbiosd
requireSip: false # TODO: Test
functions:
-
name: PersistUserEnvironmentConfiguration
@@ -1268,3 +1638,31 @@ functions:
echo "[$profile_file] No need for any action, configuration does not exist"
fi
done
-
name: DisableService
parameters:
- name: name
- name: type
code: |-
original_file='/System/Library/{{ $type }}/{{ $name }}.plist'
backup_file="$original_file.disabled"
if [ -f "$original_file" ]; then
sudo launchctl unload -w "$original_file" 2> /dev/null
mv "$original_file" "$backup_file"
echo 'Disabled successfully'
else
echo 'Already disabled'
fi
revertCode: |-
original_file='/System/Library/{{ $type }}/{{ $name }}.plist'
backup_file="$original_file.disabled"
if [ -f "$original_file" ]; then
sudo launchctl unload -w "$original_file" 2> /dev/null
if mv "$original_file" "$backup_file"; then
echo 'Disabled successfully'
else
>&2 echo 'Failed to disable'
fi
else
echo 'Already disabled'
fi

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@ 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,
@@ -25,12 +23,6 @@ export function provideDependencies(
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
return useCollectionState(context, events);
});
registerTransient(InjectionKeys.useClipboard, () => useClipboard());
registerTransient(InjectionKeys.useCurrentCode, () => {
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
const state = api.inject(InjectionKeys.useCollectionState)();
return useCurrentCode(state, events);
});
}
export interface VueDependencyInjectionApi {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,10 @@ 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'))
@@ -32,7 +36,9 @@ export default defineComponent({
TheFooter,
OptionalDevToolkit,
},
setup() { },
setup() {
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
},
});
</script>

View File

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

View File

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

View File

@@ -1,17 +1,15 @@
<template>
<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>
<button
class="button"
type="button"
@click="onClicked"
>
<AppIcon
class="button__icon"
:icon="iconName"
/>
<div class="button__text">{{text}}</div>
</button>
</template>
<script lang="ts">
@@ -51,20 +49,10 @@ 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;
@@ -82,17 +70,19 @@ 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;
}
}
.button__text {
@include hover-or-touch('>&__text') {
display: block;
}
@include hover-or-touch('>&__icon') {
display: none;
}
&__text {
display: none;
font-family: $font-artistic;
font-size: 1.5em;

View File

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

View File

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

View File

@@ -1,36 +1,168 @@
<template>
<div class="container" v-if="hasCode">
<CodeRunButton class="code-button" />
<CodeSaveButton class="code-button" />
<CodeCopyButton class="code-button" />
<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>
</div>
</template>
<script lang="ts">
import {
defineComponent, computed, inject,
defineComponent, ref, computed, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import CodeRunButton from './CodeRunButton.vue';
import CodeCopyButton from './CodeCopyButton.vue';
import CodeSaveButton from './Save/CodeSaveButton.vue';
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';
export default defineComponent({
components: {
CodeRunButton,
CodeCopyButton,
CodeSaveButton,
IconButton,
InstructionList,
ModalDialog,
},
setup() {
const { currentCode } = inject(InjectionKeys.useCurrentCode)();
const {
currentState, currentContext, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const hasCode = computed<boolean>(() => currentCode.value.length > 0);
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;
}
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">
@@ -38,10 +170,8 @@ export default defineComponent({
display: flex;
flex-direction: row;
justify-content: center;
gap: 30px;
}
.code-button {
width: 10%;
min-width: 90px;
.container > * + * {
margin-left: 30px;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@
<script lang="ts">
import {
defineComponent, ref, watch, computed,
inject, shallowRef,
inject,
} 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 = shallowRef<HTMLElement>();
const cardElement = ref<HTMLElement>();
const cardTitle = computed<string | undefined>(() => {
if (!props.categoryId || !currentState.value) {

View File

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

View File

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

View File

@@ -2,6 +2,14 @@ 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;
}
@@ -17,6 +25,7 @@ export function createFilterTriggeredEvent(
): TreeViewFilterEvent {
return {
action: TreeViewFilterAction.Triggered,
timestamp: new Date(),
predicate,
};
}
@@ -24,5 +33,6 @@ export function createFilterTriggeredEvent(
export function createFilterRemovedEvent(): TreeViewFilterEvent {
return {
action: TreeViewFilterAction.Removed,
timestamp: new Date(),
};
}

View File

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

View File

@@ -1,5 +1,5 @@
import {
WatchSource, inject, shallowRef, watch,
WatchSource, inject, ref, 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 = shallowRef<TreeNodeStateDescriptor>();
const state = ref<TreeNodeStateDescriptor>();
watch(nodeWatcher, (node: ReadOnlyTreeNode) => {
if (!node) {

View File

@@ -14,7 +14,7 @@
<script lang="ts">
import {
defineComponent, onMounted, watch,
shallowRef, PropType,
ref, 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 = shallowRef<HTMLElement | undefined>();
const treeContainerElement = ref<HTMLElement | undefined>();
const tree = new TreeRootManager();

View File

@@ -1,5 +1,5 @@
import {
WatchSource, watch, inject, shallowReadonly, shallowRef,
WatchSource, watch, inject, readonly, ref,
} 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 = shallowRef<TreeRoot | undefined>();
const nodes = shallowRef<QueryableNodes | undefined>();
const tree = ref<TreeRoot | undefined>();
const nodes = ref<QueryableNodes | undefined>();
watch(treeWatcher, (newTree) => {
tree.value = newTree;
@@ -22,6 +22,6 @@ export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
}, { immediate: true });
return {
nodes: shallowReadonly(nodes),
nodes: readonly(nodes),
};
}

View File

@@ -1,5 +1,5 @@
import {
WatchSource, inject, watch, shallowRef,
WatchSource, inject, watch, ref,
} 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 = shallowRef<NodeStateChangeEventCallback>();
const onNodeChangeCallback = ref<NodeStateChangeEventCallback>();
watch([
() => nodes.value,

View File

@@ -1,5 +1,5 @@
import {
computed, inject, shallowReadonly, shallowRef, triggerRef,
computed, inject, readonly, ref,
} 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: shallowReadonly(selectedNodeIds),
selectedScriptNodeIds: readonly(selectedNodeIds),
};
}
@@ -23,28 +23,18 @@ function useSelectedScripts() {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
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;
}
}
const selectedScripts = ref<readonly SelectedScript[]>([]);
onStateChange((state) => {
updateSelectedScripts(state.selection.selectedScripts);
selectedScripts.value = state.selection.selectedScripts;
events.unsubscribeAllAndRegister([
state.selection.changed.on((scripts) => {
updateSelectedScripts(scripts);
selectedScripts.value = scripts;
}),
]);
}, { immediate: true });
return {
selectedScripts: shallowReadonly(selectedScripts),
selectedScripts: readonly(selectedScripts),
};
}

View File

@@ -1,5 +1,5 @@
import {
Ref, inject, shallowReadonly, shallowRef,
Ref, inject, readonly, ref,
} 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 = shallowRef<TreeViewFilterEvent | undefined>(undefined);
const latestFilterEvent = ref<TreeViewFilterEvent | undefined>(undefined);
const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches(
getNodeMetadata(node),
@@ -36,7 +36,7 @@ export function useTreeViewFilterEvent() {
}, { immediate: true });
return {
latestFilterEvent: shallowReadonly(latestFilterEvent),
latestFilterEvent: readonly(latestFilterEvent),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import {
WatchSource, shallowReadonly, ref, watch,
WatchSource, readonly, 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: shallowReadonly(svgContent),
svgContent: readonly(svgContent),
};
}
@@ -62,7 +62,7 @@ const RawSvgLoaders = import.meta.glob('@/presentation/assets/icons/**/*.svg', {
});
function modifySvg(svgSource: string): string {
const parser = new window.DOMParser();
const parser = new DOMParser();
const doc = parser.parseFromString(svgSource, 'image/svg+xml');
let svgRoot = doc.documentElement;
svgRoot = removeSvgComments(svgRoot);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
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 { 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 { InjectionKey } from 'vue';
export const InjectionKeys = {
@@ -11,8 +9,6 @@ export const InjectionKeys = {
useApplication: defineSingletonKey<ReturnType<typeof useApplication>>('useApplication'),
useRuntimeEnvironment: defineSingletonKey<ReturnType<typeof useRuntimeEnvironment>>('useRuntimeEnvironment'),
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
};
function defineSingletonKey<T>(key: string): InjectionKey<T> {

View File

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

15
src/presentation/shims-tsx.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
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 Normal file
View File

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

View File

@@ -2,7 +2,7 @@ import {
describe, it, expect,
} from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, shallowRef } from 'vue';
import { defineComponent, ref } 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 = shallowRef(initialNodeData);
const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
const initialNodes = ref(initialNodeData);
const selectedLeafNodeIds = ref<readonly string[]>([]);
return {
initialNodes,
selectedLeafNodeIds,

View File

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

View File

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

View File

@@ -1,295 +1,126 @@
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('expectCharacters', () => {
describe('escapes single character as expected', () => {
const charactersToEscape = ['.', '$'];
for (const character of charactersToEscape) {
it(`escapes ${character} as expected`, () => expectMatch(
character,
describe('escape single as expected', () => {
const charactersToEscape = ['.', '$'];
for (const character of charactersToEscape) {
it(character, () => {
expectRegex(
// act
(act) => act.expectCharacters(character),
`${character}`,
));
}
});
it('escapes multiple characters as expected', () => expectMatch(
'.I have no $$.',
// assert
`\\${character}`,
);
});
}
});
it('escapes multiple as expected', () => {
expectRegex(
// act
(act) => act.expectCharacters('.I have no $$.'),
'.I have no $$.',
));
it('adds characters as expected', () => expectMatch(
'return as it is',
// assert
'\\.I have no \\$\\$\\.',
);
});
it('adds as expected', () => {
expectRegex(
// act
(act) => act.expectCharacters('return as it is'),
// assert
'return as it is',
));
);
});
});
describe('expectOneOrMoreWhitespaces', () => {
it('matches one whitespace', () => expectMatch(
' ',
(act) => act.expectOneOrMoreWhitespaces(),
' ',
));
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(),
));
});
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 ',
));
});
});
describe('captureUntilWhitespaceOrPipe', () => {
it('captures until first whitespace', () => expectCapture(
// arrange
'first ',
it('expectOneOrMoreWhitespaces', () => {
expectRegex(
// act
(act) => act.captureUntilWhitespaceOrPipe(),
(act) => act.expectOneOrMoreWhitespaces(),
// assert
'\\s+',
);
});
it('matchPipeline', () => {
expectRegex(
// act
(act) => act.matchPipeline(),
// assert
'\\s*(\\|\\s*.+?)?',
);
});
it('matchUntilFirstWhitespace', () => {
expectRegex(
// act
(act) => act.matchUntilFirstWhitespace(),
// assert
'([^|\\s]+)',
);
it('matches until first whitespace', () => expectMatch(
// arrange
'first second',
// act
(act) => act.matchUntilFirstWhitespace(),
// assert
'first',
));
it('captures until first pipe', () => expectCapture(
// arrange
'first|',
});
describe('matchMultilineAnythingExceptSurroundingWhitespaces', () => {
it('returns expected regex', () => expectRegex(
// act
(act) => act.captureUntilWhitespaceOrPipe(),
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'first',
'\\s*([\\S\\s]+?)\\s*',
));
it('captures all without whitespace or pipe', () => expectCapture(
it('matches single line', () => expectMatch(
// arrange
'all',
'single line',
// act
(act) => act.captureUntilWhitespaceOrPipe(),
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// assert
'all',
'single line',
));
it('matches single line without surrounding whitespaces', () => expectMatch(
// arrange
' single line\t',
// act
(act) => act.matchMultilineAnythingExceptSurroundingWhitespaces(),
// 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',
));
});
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(),
));
});
it('expectExpressionStart', () => {
expectRegex(
// act
(act) => act.expectExpressionStart(),
// assert
'{{\\s*',
);
});
describe('expectExpressionStart', () => {
it('matches expression start without trailing whitespaces', () => expectMatch(
'{{expression',
(act) => act.expectExpressionStart(),
'{{',
));
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(),
));
});
describe('expectExpressionEnd', () => {
it('matches expression end without preceding whitespaces', () => expectMatch(
'expression}}',
it('expectExpressionEnd', () => {
expectRegex(
// act
(act) => act.expectExpressionEnd(),
'}}',
));
it('matches expression end with preceding whitespaces', () => expectMatch(
`expression${AllWhitespaceCharacters}}}`,
(act) => act.expectExpressionEnd(),
`${AllWhitespaceCharacters}}}`,
));
it('does not capture whitespaces not directly before expression end', () => expectMatch(
'expression}} ',
(act) => act.expectExpressionEnd(),
'}}',
));
it('does not match if expression end is not present', () => expectNonMatch(
'noExpressionEndHere',
(act) => act.expectExpressionEnd(),
));
});
describe('expectOptionalWhitespaces', () => {
describe('matching', () => {
it('matches multiple Unix lines', () => expectMatch(
// arrange
'\n\n',
// act
(act) => act.expectOptionalWhitespaces(),
// assert
'\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(),
));
});
// assert
'\\s*}}',
);
});
describe('buildRegExp', () => {
it('sets global flag', () => {
@@ -303,126 +134,84 @@ describe('ExpressionRegexBuilder', () => {
expect(actual).to.equal(expected);
});
describe('can combine multiple parts', () => {
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 }}',
));
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*}}',
);
});
});
});
});
enum MatchGroupIndex {
FullMatch = 0,
FirstCapturingGroup = 1,
}
function expectCapture(
input: string,
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedCombinedCaptures: string | undefined,
): void {
function expectRegex(
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expected: string,
) {
// arrange
const matchGroupIndex = MatchGroupIndex.FirstCapturingGroup;
const sut = new ExpressionRegexBuilder();
// act
const actual = act(sut).buildRegExp().source;
// assert
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);
expect(actual).to.equal(expected);
}
function expectMatch(
input: string,
act: (regexBuilder: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedCombinedMatches: string | undefined,
matchGroupIndex = MatchGroupIndex.FullMatch,
): void {
act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder,
expectedMatch: string,
) {
// arrange
const regexBuilder = new ExpressionRegexBuilder();
act(regexBuilder);
const regex = regexBuilder.buildRegExp();
const [startMarker, endMarker] = [randomUUID(), randomUUID()];
const markedInput = `${startMarker}${input}${endMarker}`;
const builder = new ExpressionRegexBuilder()
.expectCharacters(startMarker);
act(builder);
const markedRegex = builder.expectCharacters(endMarker).buildRegExp();
// act
const allMatchGroups = Array.from(input.matchAll(regex));
const match = Array.from(markedInput.matchAll(markedRegex))
.filter((matches) => matches.length > 1)
.map((matches) => matches[1])
.filter(Boolean)
.join();
// assert
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;
expect(match).to.equal(expectedMatch);
}

View File

@@ -4,26 +4,25 @@ 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: ExpectPositionTestScenario[]) {
public expectPosition(...testCases: IExpectPositionTestCase[]) {
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(scrambledEqual(actual, testCase.expected));
expect(actual).to.deep.equal(testCase.expected);
});
}
return this;
}
public expectNoMatch(...testCases: NoMatchTestScenario[]) {
public expectNoMatch(...testCases: INoMatchTestCase[]) {
this.expectPosition(...testCases.map((testCase) => ({
name: testCase.name,
code: testCase.code,
@@ -31,7 +30,7 @@ export class SyntaxParserTestsRunner {
})));
}
public expectResults(...testCases: ExpectResultTestScenario[]) {
public expectResults(...testCases: IExpectResultTestCase[]) {
for (const testCase of testCases) {
it(testCase.name, () => {
// arrange
@@ -48,21 +47,7 @@ export class SyntaxParserTestsRunner {
return this;
}
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) {
public expectPipeHits(data: IExpectPipeHitTestData) {
for (const validPipePart of PipeTestCases.ValidValues) {
this.expectHitPipePart(validPipePart, data);
}
@@ -71,7 +56,7 @@ export class SyntaxParserTestsRunner {
}
}
private expectHitPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
private expectHitPipePart(pipeline: string, data: IExpectPipeHitTestData) {
it(`"${pipeline}" hits`, () => {
// arrange
const expectedPipePart = pipeline.trim();
@@ -88,14 +73,14 @@ export class SyntaxParserTestsRunner {
// assert
expect(expressions).has.lengthOf(1);
expect(pipelineCompiler.compileHistory).has.lengthOf(1);
const actualPipePart = pipelineCompiler.compileHistory[0].pipeline;
const actualPipeNames = pipelineCompiler.compileHistory[0].pipeline;
const actualValue = pipelineCompiler.compileHistory[0].value;
expect(actualPipePart).to.equal(expectedPipePart);
expect(actualPipeNames).to.equal(expectedPipePart);
expect(actualValue).to.equal(data.parameterValue);
});
}
private expectMissPipePart(pipeline: string, data: ExpectPipeHitTestScenario) {
private expectMissPipePart(pipeline: string, data: IExpectPipeHitTestData) {
it(`"${pipeline}" misses`, () => {
// arrange
const args = new FunctionCallArgumentCollectionStub()
@@ -113,51 +98,42 @@ export class SyntaxParserTestsRunner {
});
}
}
interface ExpectResultTestScenario {
readonly name: string;
readonly code: string;
readonly args: (
builder: FunctionCallArgumentCollectionStub,
) => FunctionCallArgumentCollectionStub;
readonly expected: readonly string[];
interface IExpectResultTestCase {
name: string;
code: string;
args: (builder: FunctionCallArgumentCollectionStub) => FunctionCallArgumentCollectionStub;
expected: readonly string[];
}
interface ExpectThrowsTestScenario {
readonly name: string;
readonly code: string;
readonly expectedError: string;
interface IExpectPositionTestCase {
name: string;
code: string;
expected: readonly ExpressionPosition[];
}
interface ExpectPositionTestScenario {
readonly name: string;
readonly code: string;
readonly expected: readonly ExpressionPosition[];
interface INoMatchTestCase {
name: string;
code: string;
}
interface NoMatchTestScenario {
readonly name: string;
readonly code: string;
}
interface ExpectPipeHitTestScenario {
readonly codeBuilder: (pipeline: string) => string;
readonly parameterName: string;
readonly parameterValue: string;
interface IExpectPipeHitTestData {
codeBuilder: (pipeline: string) => string;
parameterName: string;
parameterValue: string;
}
const PipeTestCases = {
ValidValues: [
// Single pipe with different whitespace combinations
' | pipe', ' |pipe', '|pipe', ' |pipe', ' | pipe',
' | pipe1', ' |pipe1', '|pipe1', ' |pipe1', ' | pipe1',
// Double pipes with different whitespace combinations
' | pipeFirst | pipeSecond', '| pipeFirst|pipeSecond', '|pipeFirst|pipeSecond', ' |pipeFirst |pipeSecond', '| pipeFirst | pipeSecond| pipeThird |pipeFourth',
' | 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} ',
],
InvalidValues: [
' 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',
' pipe1 |pipe2', ' pipe1',
],
};

View File

@@ -7,15 +7,15 @@ import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner';
describe('WithParser', () => {
const sut = new WithParser();
const runner = new SyntaxParserTestsRunner(sut);
describe('correctly identifies `with` syntax', () => {
describe('finds as expected', () => {
runner.expectPosition(
{
name: 'when no context variable is not used',
name: 'when no scope is not used',
code: 'hello {{ with $parameter }}no usage{{ end }} here',
expected: [new ExpressionPosition(6, 44)],
},
{
name: 'when context variable is used',
name: 'when scope is used',
code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})',
expected: [new ExpressionPosition(11, 53)],
},
@@ -25,70 +25,38 @@ describe('WithParser', () => {
expected: [new ExpressionPosition(7, 51), new ExpressionPosition(61, 99)],
},
{
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',
name: 'tolerate lack of whitespaces',
code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}',
expected: [new ExpressionPosition(15, 55)],
},
{
name: 'newlines: match multiline text',
name: '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(
{
@@ -115,73 +83,54 @@ describe('WithParser', () => {
);
});
});
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('whitespace handling inside scope', () => {
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('render scope when variable has value', () => {
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 }}',
@@ -196,71 +145,42 @@ 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('pipe behavior', () => {
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', () => {
runner.expectPipeHits({
codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`,
parameterName: 'argument',

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
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', async () => {
it('calls validator with correct options upon bootstrap', () => {
// arrange
const expectedOptions: ISanityCheckOptions = {
validateEnvironmentVariables: true,
@@ -16,11 +15,11 @@ describe('RuntimeSanityValidator', () => {
};
const sut = new RuntimeSanityValidator(validatorMock);
// act
await sut.bootstrap();
sut.bootstrap();
// assert
expect(actualOptions).to.deep.equal(expectedOptions);
});
it('propagates the error if validator fails', async () => {
it('propagates the error if validator fails', () => {
// arrange
const expectedMessage = 'message thrown from validator';
const validatorMock = () => {
@@ -28,17 +27,17 @@ describe('RuntimeSanityValidator', () => {
};
const sut = new RuntimeSanityValidator(validatorMock);
// act
const act = async () => { await sut.bootstrap(); };
const act = () => sut.bootstrap();
// assert
await expectThrowsAsync(act, expectedMessage);
expect(act).to.throw(expectedMessage);
});
it('runs successfully if validator passes', async () => {
it('runs successfully if validator passes', () => {
// arrange
const validatorMock = () => { /* NOOP */ };
const sut = new RuntimeSanityValidator(validatorMock);
// act
const act = async () => { await sut.bootstrap(); };
const act = () => sut.bootstrap();
// assert
await expectDoesNotThrowAsync(act);
expect(act).to.not.throw();
});
});

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { describe } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/MacOsInstructionsBuilder';
import { MacOsInstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/MacOsInstructionsBuilder';
import { runOsSpecificInstructionBuilderTests } from './OsSpecificInstructionBuilderTestRunner';
describe('MacOsInstructionsBuilder', () => {

View File

@@ -1,6 +1,6 @@
import { it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
interface ITestData {
readonly factory: () => InstructionsBuilder;

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory';
import { getInstructions, hasInstructions } from '@/presentation/components/Code/CodeButtons/Instructions/InstructionListDataFactory';
import { getEnumValues } from '@/application/Common/Enum';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Instructions/Data/InstructionsBuilder';
describe('InstructionListDataFactory', () => {
const supportedOsList = [OperatingSystem.macOS];

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { VueWrapper, shallowMount } from '@vue/test-utils';
import { Wrapper, 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: [
FilterChangeDetailsStub.forClear(),
FilterChange.forClear(),
],
expectedComponent: CardList,
componentsToDisappear: [ScriptsTree],
@@ -132,7 +132,7 @@ describe('TheScriptsView.vue', () => {
name: 'tree on search',
initialView: ViewType.Cards,
changeEvents: [
FilterChangeDetailsStub.forApply(
FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
),
],
@@ -143,10 +143,10 @@ describe('TheScriptsView.vue', () => {
name: 'return to card after search',
initialView: ViewType.Cards,
changeEvents: [
FilterChangeDetailsStub.forApply(
FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
),
FilterChangeDetailsStub.forClear(),
FilterChange.forClear(),
],
expectedComponent: CardList,
componentsToDisappear: [ScriptsTree],
@@ -155,10 +155,10 @@ describe('TheScriptsView.vue', () => {
name: 'return to tree after search',
initialView: ViewType.Tree,
changeEvents: [
FilterChangeDetailsStub.forApply(
FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
),
FilterChangeDetailsStub.forClear(),
FilterChange.forClear(),
],
expectedComponent: ScriptsTree,
componentsToDisappear: [CardList],
@@ -223,11 +223,11 @@ describe('TheScriptsView.vue', () => {
});
// act
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
filterStub.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
));
await wrapper.vm.$nextTick();
filterStub.notifyFilterChange(FilterChangeDetailsStub.forClear());
filterStub.notifyFilterChange(FilterChange.forClear());
await wrapper.vm.$nextTick();
// assert
@@ -264,7 +264,7 @@ describe('TheScriptsView.vue', () => {
});
// act
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
filterStub.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
));
await wrapper.vm.$nextTick();
@@ -283,7 +283,7 @@ describe('TheScriptsView.vue', () => {
const wrapper = mountComponent({
useCollectionState: stateStub.get(),
});
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
filterStub.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withQueryAndSomeMatches(),
));
await wrapper.vm.$nextTick();
@@ -359,7 +359,7 @@ describe('TheScriptsView.vue', () => {
});
// act
filterStub.notifyFilterChange(FilterChangeDetailsStub.forApply(
filterStub.notifyFilterChange(FilterChange.forApply(
filter,
));
await wrapper.vm.$nextTick();
@@ -379,10 +379,10 @@ describe('TheScriptsView.vue', () => {
});
// act
filter.notifyFilterChange(FilterChangeDetailsStub.forApply(
filter.notifyFilterChange(FilterChange.forApply(
new FilterResultStub().withSomeMatches(),
));
filter.notifyFilterChange(FilterChangeDetailsStub.forClear());
filter.notifyFilterChange(FilterChange.forClear());
await wrapper.vm.$nextTick();
// expect
@@ -392,7 +392,7 @@ describe('TheScriptsView.vue', () => {
});
});
function expectComponentsToNotExist(wrapper: VueWrapper, components: readonly unknown[]) {
function expectComponentsToNotExist(wrapper: Wrapper<Vue>, components: readonly unknown[]) {
const existingUnexpectedComponents = components
.map((component) => wrapper.findComponent(component))
.filter((component) => component.exists());
@@ -404,17 +404,15 @@ function mountComponent(options?: {
readonly viewType?: ViewType,
}) {
return shallowMount(TheScriptsView, {
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(),
},
provide: {
[InjectionKeys.useCollectionState as symbol]:
() => options?.useCollectionState ?? new UseCollectionStateStub().get(),
[InjectionKeys.useApplication as symbol]:
new UseApplicationStub().get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
props: {
propsData: {
currentView: options?.viewType === undefined ? ViewType.Tree : options.viewType,
},
});

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
TreeViewFilterAction, TreeViewFilterPredicate,
TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate,
createFilterRemovedEvent, createFilterTriggeredEvent,
} from '@/presentation/components/Scripts/View/Tree/TreeView/Bindings/TreeInputFilterEvent';
@@ -47,6 +47,19 @@ 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', () => {
@@ -66,6 +79,19 @@ 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);
});
});
});

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
shallowRef, defineComponent, WatchSource, nextTick,
ref, 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 = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const nodeWatcher = ref<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 = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const nodeWatcher = ref<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 = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const nodeWatcher = ref<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 = shallowRef<ReadOnlyTreeNode | undefined>(undefined);
const nodeWatcher = ref<ReadOnlyTreeNode | undefined>(undefined);
const stateAccessStub = new TreeNodeStateAccessStub();
const expectedChangedState = new TreeNodeStateDescriptorStub();
nodeWatcher.value = new TreeNodeStub()
@@ -77,11 +77,9 @@ function mountWrapperComponent(nodeWatcher: WatchSource<ReadOnlyTreeNode | undef
template: '<div></div>',
}),
{
global: {
provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
},
);

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
shallowRef, defineComponent, WatchSource, nextTick,
ref, 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 = shallowRef<TreeRoot>(new TreeRootStub().withCollection(
const treeWatcher = ref<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 = shallowRef(
const treeWatcher = ref(
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 = shallowRef(new TreeRootStub().withCollection(treeCollectionStub));
const treeWatcher = ref(new TreeRootStub().withCollection(treeCollectionStub));
const { returnObject } = mountWrapperComponent(treeWatcher);
@@ -68,11 +68,9 @@ function mountWrapperComponent(treeWatcher: WatchSource<TreeRoot | undefined>) {
template: '<div></div>',
}),
{
global: {
provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
provide: {
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
},
);

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
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';
@@ -9,10 +8,9 @@ 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 an empty array when no scripts are selected', () => {
it('returns empty array when no scripts are selected', () => {
// arrange
const { useStateStub, returnObject } = mountWrapperComponent();
useStateStub.withState(new CategoryCollectionStateStub().withSelectedScripts([]));
@@ -21,213 +19,38 @@ describe('useSelectedScriptNodeIds', () => {
// assert
expect(actualIds).to.have.lengthOf(0);
});
it('initially registers the unsubscribe callback', () => {
it('returns correct node IDs for selected scripts', () => {
// arrange
const eventsStub = new UseAutoUnsubscribedEventsStub();
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,
});
// act
mountWrapperComponent({
useAutoUnsubscribedEvents: eventsStub,
});
const actualIds = returnObject.selectedScriptNodeIds.value;
// assert
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);
});
});
const expectedNodeIds = [...parsedNodeIds.values()];
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
expect(actualIds).to.include.members(expectedNodeIds);
});
});
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?: ScriptNodeIdParser,
readonly useAutoUnsubscribedEvents?: UseAutoUnsubscribedEventsStub,
readonly scriptNodeIdParser?: typeof getScriptNodeId,
}) {
const useStateStub = new UseCollectionStateStub();
const nodeIdParser: ScriptNodeIdParser = scenario?.scriptNodeIdParser
const nodeIdParser: typeof getScriptNodeId = scenario?.scriptNodeIdParser
?? ((script) => script.id);
let returnObject: ReturnType<typeof useSelectedScriptNodeIds>;
@@ -237,13 +60,11 @@ function mountWrapperComponent(scenario?: {
},
template: '<div></div>',
}, {
global: {
provide: {
[InjectionKeys.useCollectionState as symbol]:
() => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => (scenario?.useAutoUnsubscribedEvents ?? new UseAutoUnsubscribedEventsStub()).get(),
},
provide: {
[InjectionKeys.useCollectionState as symbol]:
() => useStateStub.get(),
[InjectionKeys.useAutoUnsubscribedEvents as symbol]:
() => new UseAutoUnsubscribedEventsStub().get(),
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

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