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
85 changed files with 2604 additions and 3839 deletions

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

View File

@@ -2,142 +2,79 @@
## Benefits of templating ## Benefits of templating
- **Code sharing:** Share code across scripts for consistent practices and easier maintenance. - Generating scripts by sharing code to increase best-practice usage and maintainability.
- **Script independence:** Generate self-contained scripts, eliminating the need for external code. - Creating self-contained scripts without cross-dependencies.
- **Cleaner code:** Use pipes for complex operations, resulting in more readable and streamlined code. - Use of pipes for writing cleaner code and letting pipes do dirty work.
## Expressions ## 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 `}}`. ```go
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 }} {{ with $condition }}
echo {{ $text }} echo {{ $text }}
{{ end }} {{ end }}
``` ```
### Parameter substitution ### Parameter substitution
Parameter substitution dynamically replaces variable references with their corresponding values in the script. A simple function example:
**Example function:**
```yaml ```yaml
name: DisplayTextFunction function: EchoArgument
parameters: parameters:
- name: 'text' - name: 'argument'
code: echo {{ $text }} 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 ### 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:** 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:
If the provided variable is falsy (`false`, `null`, or empty), the compiler skips the enclosed block of code.
A "block" lies between the with start (`{{ with .. }}`) and end (`{{ end }}`) expressions, defining its boundaries.
Example:
```go
{{ with $optionalVariable }}
Hello
{{ end }}
```
This would display `Hello` if `$optionalVariable` is truthy.
**Parameter declaration:**
You should set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
Declare parameters used for `with` condition as optional such as:
```yaml
name: ConditionalOutputFunction
parameters:
- name: 'data'
optional: true
code: |-
{{ with $data }}
Data is: {{ . }}
{{ end }}
```
**Context variable:**
`with` statement binds its context (value of the provided parameter value) as arbitrary `.` value.
`{{ . }}` syntax gives you access to the context variable.
This is optional to use, and not required to use `with` expressions.
For example:
```go ```go
{{ with $parameterName }}Parameter value is {{ . }} here {{ end }} {{ with $parameterName }}Parameter value is {{ . }} here {{ end }}
``` ```
**Multiline text:** It supports multiline text inside the block. You can have something like:
It supports multiline text inside the block. You can write something like:
```go ```go
{{ with $argument }} {{ with $argument }}
@@ -146,9 +83,7 @@ It supports multiline text inside the block. You can write something like:
{{ end }} {{ end }}
``` ```
**Inner expressions:** You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution):
You can also embed other expressions inside its block, such as [parameter substitution](#parameter-substitution):
```go ```go
{{ with $condition }} {{ with $condition }}
@@ -156,44 +91,32 @@ You can also embed other expressions inside its block, such as [parameter substi
{{ end }} {{ 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 Example:
{{ with $condition1 }}
Value of $condition1: {{ . }} ```yaml
{{ with $condition2 }} function: FunctionThatOutputsConditionally
Value of $condition2: {{ . }} parameters:
{{ end }} - name: 'argument'
optional: true
code: |-
{{ with $argument }}
Value is: {{ . }}
{{ end }} {{ end }}
``` ```
### Pipes ### Pipes
Pipes are functions designed for text manipulation. - Pipes are functions available for handling text.
They allow for a sequential application of operations resembling [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), also known as "chaining". - Allows stacking actions one after another also known as "chaining".
Each pipeline's output becomes the input of the following pipe. - 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.
**Pre-defined**: - 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.
Pipes are pre-defined by the system. - **Existing pipes**
You cannot create pipes in [collection files](./collection-files.md). - `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
[A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files. - `escapeDoubleQuotes`: Escapes `"` characters, allows you to use them inside double quotes (`"`).
- **Example usages**
**Compatibility:** - `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}`
- `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}`
You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
For example:
```go
{{ with $script }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}
```
**Naming:**
❗ Pipe names must be camelCase without any space or special characters.
**Available pipes:**
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
- `escapeDoubleQuotes`: Escapes `"` characters for batch command execution, allows you to use them inside double quotes (`"`).

View File

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

1181
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
"author": "undergroundwires", "author": "undergroundwires",
"type": "module", "type": "module",
"main": "./dist-electron-unbundled/main/index.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
@@ -42,7 +43,7 @@
"electron-updater": "^6.1.4", "electron-updater": "^6.1.4",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"markdown-it": "^13.0.2", "markdown-it": "^13.0.2",
"vue": "^3.3.7" "vue": "^2.7.14"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4", "@modyfi/vite-plugin-yaml": "^1.0.4",
@@ -52,17 +53,17 @@
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-legacy": "^4.1.1", "@vitejs/plugin-legacy": "^4.1.1",
"@vitejs/plugin-vue": "^4.4.0", "@vitejs/plugin-vue2": "^2.2.0",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0", "@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^2.4.1", "@vue/test-utils": "^1.3.6",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"cypress": "^13.3.1", "cypress": "^13.3.1",
"electron": "^27.0.0", "electron": "^27.0.0",
"electron-builder": "^24.6.4", "electron-builder": "^24.6.4",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1", "electron-icon-builder": "^2.0.1",
"electron-vite": "^1.0.28", "electron-vite": "^1.0.27",
"eslint": "^8.51.0", "eslint": "^8.51.0",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.17.0", "eslint-plugin-vue": "^9.17.0",
@@ -97,11 +98,5 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/undergroundwires/privacy.sexy.git" "url": "https://github.com/undergroundwires/privacy.sexy.git"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
},
"//optionalDependencies": {
"dmg-license": "Required by `electron-builder` for DMG builds on macOS, https://github.com/electron-userland/electron-builder/issues/6489, https://github.com/electron-userland/electron-builder/issues/6520"
} }
} }

View File

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

View File

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

View File

@@ -707,9 +707,15 @@ actions:
- -
category: Clear Firefox history category: Clear Firefox history
docs: |- 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: children:
- -
name: Clear Firefox cache name: Clear Firefox cache
@@ -749,13 +755,9 @@ actions:
# Snap installation # Snap installation
rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/* rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/*
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: crashes/* path: crashes/
-
function: DeleteFilesFromFirefoxProfiles
parameters:
pathGlob: crashes/events/*
- -
name: Clear Firefox cookies name: Clear Firefox cookies
docs: |- 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" [1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: cookies.sqlite path: cookies.sqlite
- -
name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.) name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.)
# This script (name, documentation and code) is same in Linux and Windows collections.
# Changes should be done at both places.
# Marked: refactor-with-partials
docs: |- docs: |-
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] **Downloads:**
- Browsing history, a record of pages visited (`moz_places`, `moz_historyvisits`) [1] Firefox downloads are stored in the 'places.sqlite' database, within the 'moz_annos' table [5].
- Keywords and typed URLs (`moz_keywords`, `moz_inputhistory`) [1] The entries in `moz_annos` are linked to `moz_places` that store the actual history entry (`moz_places.id = moz_annos.place_id`) [6].
- Item annotations (`moz_items_annos`) [1] Associated URL information is stored within the 'moz_places' table [5].
- Bookmark roots such as places, menu, toolbar, tags, unfiled (`moz_bookmarks_roots`) [1] 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]. **Favicons:**
Due to these connections, the script removes entries from all relevant tables simultaneously to maintain database integrity. 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].
**Bookmarks**: Stored across several tables (`moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots`) [3], with additional undocumented tables like `moz_bookmarks_deleted` [4].
**Downloads**: Stored in the 'places.sqlite' database, within the 'moz_annos' table [5]. The entries in `moz_annos` are linked to `moz_places` that store the actual history entry
(`moz_places.id = moz_annos.place_id`) [6]. Associated URL information is stored within the 'moz_places' table [5]. Downloads have been historically stored in `downloads.rdf` for Firefox 2.x
and below [7], and `downloads.sqlite` later on [7].
**Favicons**: Older Firefox versions stored favicons in `places.sqlite` within the `moz_favicons` table [5], while newer versions use `favicons.sqlite` and the `moz_icons` table [5].
By executing this script, users can ensure their Firefox browsing history, bookmarks, and downloads are thoroughly removed, contributing to a cleaner and more private browsing experience.
[1]: https://web.archive.org/web/20221029141626/https://kb.mozillazine.org/Places.sqlite "Places.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org" [1]: https://web.archive.org/web/20221029141626/https://kb.mozillazine.org/Places.sqlite "Places.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
[2]: https://web.archive.org/web/20221030160803/https://wiki.mozilla.org/images/0/08/Places.sqlite.schema.pdf "Places.sqlite.schema.pdf | Mozilla Wiki" [2]: https://web.archive.org/web/20221030160803/https://wiki.mozilla.org/images/0/08/Places.sqlite.schema.pdf "Places.sqlite.schema.pdf | Mozilla Wiki"
@@ -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" [7]: https://web.archive.org/web/20221029145712/https://kb.mozillazine.org/Downloads.rdf "Downloads.rdf | MozillaZine Knowledge Base | kb.mozillazine.org"
call: call:
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: downloads.rdf path: downloads.rdf
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: downloads.sqlite path: downloads.sqlite
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: places.sqlite path: places.sqlite
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: favicons.sqlite path: favicons.sqlite
- -
name: Clear Firefox logins name: Clear Firefox logins
docs: |- 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" [2]: https://web.archive.org/web/20221029145757/https://bugzilla.mozilla.org/show_bug.cgi?id=1593467 "1593467 - Automatically restore from logins-backup.json when logins.json is missing or corrupt | Bugzilla | mozilla.org | bugzilla.mozilla.org"
call: call:
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: logins.json path: logins.json
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: logins-backup.json path: logins-backup.json
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: signons.sqlite path: signons.sqlite
- -
name: Clear Firefox autocomplete history name: Clear Firefox autocomplete history
docs: |- 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" [1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: formhistory.sqlite path: formhistory.sqlite
- -
name: Clear Firefox "Multi-Account Containers" data name: Clear Firefox "Multi-Account Containers" data
docs: |- 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" [1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: containers.json path: containers.json
- -
name: Clear Firefox open tabs and windows data name: Clear Firefox open tabs and windows data
docs: |- 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" [1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: sessionstore.jsonlz4 path: sessionstore.jsonlz4
- -
category: Clear system and kernel usage data category: Clear system and kernel usage data
docs: |- docs: |-
@@ -2905,8 +2911,7 @@ actions:
function: AddFirefoxPrefs function: AddFirefoxPrefs
parameters: parameters:
prefName: toolkit.telemetry.log.level prefName: toolkit.telemetry.log.level
jsonValue: >- jsonValue: 'Fatal'
"Fatal"
- -
name: Disable Firefox telemetry log output name: Disable Firefox telemetry log output
recommend: standard recommend: standard
@@ -2919,8 +2924,7 @@ actions:
function: AddFirefoxPrefs function: AddFirefoxPrefs
parameters: parameters:
prefName: toolkit.telemetry.log.dump prefName: toolkit.telemetry.log.dump
jsonValue: >- jsonValue: 'Fatal'
"Fatal"
- -
name: Clear Firefox telemetry user ID name: Clear Firefox telemetry user ID
recommend: standard recommend: standard
@@ -3487,66 +3491,16 @@ functions:
>&2 echo "Failed, $service does not exist." >&2 echo "Failed, $service does not exist."
fi fi
- -
name: Comment name: DeleteFromFirefoxProfiles
# 💡 Purpose:
# Adds a comment in the executed code for better readability and debugging.
# This function does not affect the execution flow but helps in understanding the purpose of subsequent code.
parameters:
- name: codeComment
optional: true
- name: revertCodeComment
optional: true
call:
function: RunInlineCode
parameters:
code: '{{ with $codeComment }}# {{ . }}{{ end }}'
revertCode: '{{ with $revertCodeComment }}# {{ . }}{{ end }}'
-
name: DeleteFiles
parameters:
- name: fileGlob
call:
-
function: Comment
parameters:
codeComment: >-
Delete files matching pattern: "{{ $fileGlob }}"
-
function: RunPython3Code
parameters: parameters:
- name: path # file or folder in profile file
code: |- code: |-
import glob # {{ $path }}: Global installation
import os rm -rfv ~/.mozilla/firefox/*/{{ $path }}
path = '{{ $fileGlob }}' # {{ $path }}: Flatpak installation
expanded_path = os.path.expandvars(os.path.expanduser(path)) rm -rfv ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $path }}
print(f'Deleting files matching pattern: {expanded_path}') # {{ $path }}: Snap installation
paths = glob.glob(expanded_path) rm -rfv ~/snap/firefox/common/.mozilla/firefox/*/{{ $path }}
if not paths:
print('Skipping, no paths found.')
for path in paths:
if not os.path.isfile(path):
print(f'Skipping folder: "{path}".')
continue
os.remove(path)
print(f'Successfully delete file: "{path}".')
print(f'Successfully deleted {len(paths)} file(s).')
-
name: DeleteFilesFromFirefoxProfiles
parameters:
- name: pathGlob # file or folder in profile file
call:
- # Global installation
function: DeleteFiles
parameters:
fileGlob: ~/.mozilla/firefox/*/{{ $pathGlob }}
- # Flatpak installation
function: DeleteFiles
parameters:
fileGlob: ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $pathGlob }}
- # Snap installation
function: DeleteFiles
parameters:
fileGlob: ~/snap/firefox/common/.mozilla/firefox/*/{{ $pathGlob }}
- -
name: CleanTableFromFirefoxProfileDatabase name: CleanTableFromFirefoxProfileDatabase
parameters: parameters:

View File

@@ -1238,6 +1238,376 @@ actions:
sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate 'CriticalUpdateInstall' -bool true sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate 'CriticalUpdateInstall' -bool true
# Trigger background check with normal scan (critical updates only) # Trigger background check with normal scan (critical updates only)
sudo softwareupdate --background-critical 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: functions:
- -
name: PersistUserEnvironmentConfiguration name: PersistUserEnvironmentConfiguration
@@ -1268,3 +1638,31 @@ functions:
echo "[$profile_file] No need for any action, configuration does not exist" echo "[$profile_file] No need for any action, configuration does not exist"
fi fi
done 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

@@ -35,7 +35,8 @@
} }
.#{$name}-leave-active, .#{$name}-leave-active,
.#{$name}-enter-from .#{$name}-enter, // Vue 2.X compatibility
.#{$name}-enter-from // Vue 3.X compatibility
{ {
opacity: 0; 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 { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
import { AppInitializationLogger } from './Modules/AppInitializationLogger'; import { AppInitializationLogger } from './Modules/AppInitializationLogger';
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
import type { App } from 'vue';
export class ApplicationBootstrapper implements Bootstrapper { export class ApplicationBootstrapper implements IVueBootstrapper {
constructor(private readonly bootstrappers = ApplicationBootstrapper.getAllBootstrappers()) { } public bootstrap(vue: VueConstructor): void {
const bootstrappers = ApplicationBootstrapper.getAllBootstrappers();
public async bootstrap(app: App): Promise<void> { for (const bootstrapper of bootstrappers) {
for (const bootstrapper of this.bootstrappers) { bootstrapper.bootstrap(vue);
// eslint-disable-next-line no-await-in-loop
await bootstrapper.bootstrap(app); // Not running `Promise.all` because order matters.
} }
} }
private static getAllBootstrappers(): Bootstrapper[] { private static getAllBootstrappers(): IVueBootstrapper[] {
return [ return [
new VueBootstrapper(),
new RuntimeSanityValidator(), new RuntimeSanityValidator(),
new DependencyBootstrapper(),
new AppInitializationLogger(), new AppInitializationLogger(),
]; ];
} }

View File

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

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 { ILogger } from '@/infrastructure/Log/ILogger';
import { Bootstrapper } from '../Bootstrapper'; import { IVueBootstrapper } from '../IVueBootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory'; import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements Bootstrapper { export class AppInitializationLogger implements IVueBootstrapper {
constructor( constructor(
private readonly logger: ILogger = ClientLoggerFactory.Current.logger, 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. // Do not remove [APP_INIT]; it's a marker used in tests.
this.logger.info('[APP_INIT] Application is initialized.'); this.logger.info('[APP_INIT] Application is initialized.');
} }

View File

@@ -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 { 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) { constructor(private readonly validator = validateRuntimeSanity) {
} }
public async bootstrap(): Promise<void> { public bootstrap(): void {
this.validator({ this.validator({
validateEnvironmentVariables: true, validateEnvironmentVariables: true,
validateWindowVariables: 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 TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue'; import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue'; import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { provideDependencies } from '../bootstrapping/DependencyProvider';
const singletonAppContext = await buildContext();
const OptionalDevToolkit = process.env.NODE_ENV !== 'production' const OptionalDevToolkit = process.env.NODE_ENV !== 'production'
? defineAsyncComponent(() => import('@/presentation/components/DevToolkit/DevToolkit.vue')) ? defineAsyncComponent(() => import('@/presentation/components/DevToolkit/DevToolkit.vue'))
@@ -32,7 +36,9 @@ export default defineComponent({
TheFooter, TheFooter,
OptionalDevToolkit, OptionalDevToolkit,
}, },
setup() { }, setup() {
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
},
}); });
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

18
tst.sh Executable file
View File

@@ -0,0 +1,18 @@
echo '--- Disable Location-Based Suggestions for Siri'
if $(csrutil status | grep 'enabled'); then
echo 'SIP must be disabled'
exit 1
fi
original_file='/System/Library/LaunchAgents/com.apple.parsecd.plist'
backup_file="/Users/tst/aq.disabled"
if [ -f "$original_file" ]; then
sudo launchctl unload -w "$original_file" 2> /dev/null
if sudo mv "$original_file" "$backup_file"; then
echo 'Disabled successfully'
else
>&2 echo 'Failed to disable'
fi
else
echo 'Already disabled'
fi

View File

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