add support for shared functions #41

This commit is contained in:
undergroundwires
2020-09-17 21:46:20 +01:00
parent 1a9db31c77
commit 8ce06facbd
27 changed files with 1533 additions and 354 deletions

View File

@@ -25,30 +25,6 @@
## Guidelines
### Extend scripts
- Create a [pull request](#Pull-Request-Process) for [application.yaml](./src/application/application.yaml)
- 🙏 For any new script, please add `revertCode` and `docs` values if possible.
- Structure of `script` object:
- `name`: *`string`* (**required**)
- Name of the script
- E.g. `Disable targeted ads`
- `code`: *`string`* (**required**)
- Batch file commands that will be executed
- `docs`: *`string`* | `[ string, ... ]`
- Documentation URL or list of URLs for those who wants to learn more about the script
- E.g. `https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_telemetry`
- `revertCode`: `string`
- Code that'll undo the change done by `code` property.
- E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1`
- then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0`
- `recommend`: `"standard"` | `"strict"` | `undefined` (default)
- If not defined then the script will not be recommended
- If defined it can be either
- `standard`: Will be recommended for general users
- `strict`: Will only be recommended with a warning
- See [typings](./src/application/application.yaml.d.ts) for documentation as code.
### Handle the state in presentation layer
- There are two types of components:

View File

@@ -27,13 +27,14 @@
- No need to run any compiled software that has access to your system, just run the generated scripts
- Have full visibility into what the tweaks do as you enable them
- Ability to revert (undo) applied scripts
- Everything is transparent: both application and its infrastructure are open-source and automated
- Easily extendable
- Everything is open-source and automated (both application and its infrastructure)
## Extend scripts
- Fork it & add more scripts in [application.yaml](src/application/application.yaml) and send a pull request 👌
- 📖 More: [extend scripts | CONTRIBUTING.md](./CONTRIBUTING.md#extend-scripts)
- 📖 If you're unsure about the syntax you can refer to the [application file | documentation](docs/application-file.md).
- 🙏 For any new script, please add `revertCode` and `docs` values if possible.
## Commands

139
docs/application-file.md Normal file
View File

@@ -0,0 +1,139 @@
# Application file
- privacy.sexy is a data-driven application where it reads the necessary OS-specific logic from [`application.yaml`](./../src/application/application.yaml)
- 💡 Best practices
- If you repeat yourself, try to utilize [YAML-defined functions](#function)
- Always try to add documentation and a way to revert a tweak in [scripts](#script)
- 📖 Types in code: [`application.d.ts`](./../src/application/application.yaml.d.ts)
## Objects
### `Application`
- Application file simply defines different categories and their scripts in a tree structure.
- Application file also allows defining common [function](#function)s to be used throughout the application if you'd like different scripts to share same code.
#### `Application` syntax
- `actions: [` ***[`Category`](#Category)*** `, ... ]` **(required)**
- Each [category](#category) is rendered as different cards in card presentation.
- ❗ Application must consist of at least one category.
- `functions: [` ***[`Function`](#Function)*** `, ... ]`
- Functions are optionally defined to re-use the same code throughout different scripts.
### `Category`
- Category has a parent that has tree-like structure where it can have subcategories or subscripts.
- It's a logical grouping of different scripts and other categories.
#### `Category` syntax
- `category:` *`string`* (**required**)
- Name of the category
- ❗ Must be unique throughout the application
- `children: [` ***[`Category`](#category)*** `|` [***`Script`***](#Script) `, ... ]` (**required**)
- ❗ Category must consist of at least one subcategory or script.
- Children can be combination of scripts and subcategories.
### `Script`
- Script represents a single tweak.
- A script must include either:
- A `code` and `revertCode`
- Or `call` to call YAML-defined functions
- 🙏 For any new script, please add `revertCode` and `docs` values if possible.
#### `Script` syntax
- `name`: *`string`* (**required**)
- Name of the script
- ❗ Must be unique throughout the application
- E.g. `Disable targeted ads`
- `code`: *`string`* (may be **required**)
- Batch file commands that will be executed
- 💡 If defined, best practice to also define `revertCode`
- ❗ If not defined `call` must be defined, do not define if `call` is defined.
- `revertCode`: `string`
- Code that'll undo the change done by `code` property.
- E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1`
- then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0`
- ❗ Do not define if `call` is defined.
- `call`: ***[`FunctionCall`](#FunctionCall)*** | `[` ***[`FunctionCall`](#FunctionCall)*** `, ... ]` (may be **required**)
- A shared function or sequence of functions to call (called in order)
- ❗ If not defined `code` must be defined
- `docs`: *`string`* | `[`*`string`*`, ... ]`
- Single documentation URL or list of URLs for those who wants to learn more about the script
- E.g. `https://docs.microsoft.com/en-us/windows-server/`
- `recommend`: `"standard"` | `"strict"` | `undefined` (default)
- If not defined then the script will not be recommended
- If defined it can be either
- `standard`: Only non-breaking scripts without limiting OS functionality
- `strict`: Scripts that can break certain functionality in favor of privacy and security
### `FunctionCall`
- Describes a single call to a function by optionally providing values to its parameters.
- 👀 See [parameter substitution](#parameter-substitution) for an example usage
#### `FunctionCall` syntax
- `function`: *`string`* (**required**)
- Name of the function to call.
- ❗ Function with same name must defined in `functions` property of [Application](#application)
- `parameters`: `[ parameterName:` *`parameterValue`*`, ... ]`
- Defines key value dictionary for each parameter and its value
- E.g.
```yaml
parameters:
userDefinedParameterName: parameterValue
# ...
appName: Microsoft.WindowsFeedbackHub
```
### `Function`
- Functions allow re-usable code throughout the defined scripts.
- Functions are templates compiled by privacy.sexy and uses special expressions.
- Expressions are defined inside mustaches (double brackets, `{{` and `}}`)
- 👀 See [parameter substitution](#parameter-substitution) for an example usage
#### Parameter substitution
A simple function example
```yaml
function: EchoArgument
parameters: [ 'argument' ]
code: Hello {{ $argument }} !
```
It would print "Hello world" if it's called in a [script](#script) as following:
```yaml
script: Echo script
call:
function: EchoArgument
parameters:
argument: World
```
#### `Function` syntax
- `name`: *`string`* (**required**)
- Name of the function that scripts will use.
- Convention is to use camelCase, and be verbs.
- E.g. `uninstallStoreApp`
- ❗ Function names must be unique
- `parameters`: `[` *`string`* `, ... ]`
- Name of the parameters that the function has.
- Parameter values are provided by a [Script](#script) through a [FunctionCall](#functioncall)
- Parameter names must be defined to be used in expressions such as [parameter substitution](#parameter-substitution)
- ❗ Parameter names must be unique
`code`: *`string`* (**required**)
- Batch file commands that will be executed
- 💡 If defined, best practice to also define `revertCode`
- `revertCode`: *`string`*
- Code that'll undo the change done by `code` property.
- E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1`
- then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0`

View File

@@ -4,14 +4,16 @@ import { IApplication } from '@/domain/IApplication';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { ApplicationYaml } from 'js-yaml-loader!./../application.yaml';
import { parseCategory } from './CategoryParser';
import { ProjectInformation } from '../../domain/ProjectInformation';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { ScriptCompiler } from './Compiler/ScriptCompiler';
export function parseApplication(content: ApplicationYaml, env: NodeJS.ProcessEnv = process.env): IApplication {
validate(content);
const compiler = new ScriptCompiler(content.functions);
const categories = new Array<Category>();
for (const action of content.actions) {
const category = parseCategory(action);
const category = parseCategory(action, compiler);
categories.push(category);
}
const info = readAppInformation(env);
@@ -21,7 +23,7 @@ export function parseApplication(content: ApplicationYaml, env: NodeJS.ProcessEn
return app;
}
function readAppInformation(environment): IProjectInformation {
function readAppInformation(environment: NodeJS.ProcessEnv): IProjectInformation {
return new ProjectInformation(
environment.VUE_APP_NAME,
environment.VUE_APP_VERSION,

View File

@@ -3,6 +3,7 @@ import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category';
import { parseDocUrls } from './DocumentationParser';
import { parseScript } from './ScriptParser';
import { IScriptCompiler } from './Compiler/IScriptCompiler';
let categoryIdCounter: number = 0;
@@ -11,14 +12,17 @@ interface ICategoryChildren {
subScripts: Script[];
}
export function parseCategory(category: YamlCategory): Category {
export function parseCategory(category: YamlCategory, compiler: IScriptCompiler): Category {
if (!compiler) {
throw new Error('undefined compiler');
}
ensureValid(category);
const children: ICategoryChildren = {
subCategories: new Array<Category>(),
subScripts: new Array<Script>(),
};
for (const categoryOrScript of category.children) {
parseCategoryChild(categoryOrScript, children, category);
parseCategoryChild(categoryOrScript, children, category, compiler);
}
return new Category(
/*id*/ categoryIdCounter++,
@@ -42,13 +46,16 @@ function ensureValid(category: YamlCategory) {
}
function parseCategoryChild(
categoryOrScript: any, children: ICategoryChildren, parent: YamlCategory) {
categoryOrScript: any,
children: ICategoryChildren,
parent: YamlCategory,
compiler: IScriptCompiler) {
if (isCategory(categoryOrScript)) {
const subCategory = parseCategory(categoryOrScript as YamlCategory);
const subCategory = parseCategory(categoryOrScript as YamlCategory, compiler);
children.subCategories.push(subCategory);
} else if (isScript(categoryOrScript)) {
const yamlScript = categoryOrScript as YamlScript;
const script = parseScript(yamlScript);
const script = parseScript(yamlScript, compiler);
children.subScripts.push(script);
} else {
throw new Error(`Child element is neither a category or a script.
@@ -57,7 +64,8 @@ function parseCategoryChild(
}
function isScript(categoryOrScript: any): boolean {
return categoryOrScript.code && categoryOrScript.code.length > 0;
return (categoryOrScript.code && categoryOrScript.code.length > 0)
|| categoryOrScript.call;
}
function isCategory(categoryOrScript: any): boolean {

View File

@@ -0,0 +1,7 @@
import { IScriptCode } from '@/domain/IScriptCode';
import { YamlScript } from 'js-yaml-loader!./application.yaml';
export interface IScriptCompiler {
canCompile(script: YamlScript): boolean;
compile(script: YamlScript): IScriptCode;
}

View File

@@ -0,0 +1,200 @@
import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import { YamlScript, YamlFunction, FunctionCall, ScriptFunctionCall, FunctionCallParameters } from 'js-yaml-loader!./application.yaml';
import { IScriptCompiler } from './IScriptCompiler';
interface ICompiledCode {
readonly code: string;
readonly revertCode: string;
}
export class ScriptCompiler implements IScriptCompiler {
constructor(private readonly functions: readonly YamlFunction[]) {
ensureValidFunctions(functions);
}
public canCompile(script: YamlScript): boolean {
if (!script.call) {
return false;
}
return true;
}
public compile(script: YamlScript): IScriptCode {
this.ensureCompilable(script.call);
const compiledCodes = new Array<ICompiledCode>();
const calls = getCallSequence(script.call);
calls.forEach((currentCall, currentCallIndex) => {
ensureValidCall(currentCall, script.name);
const commonFunction = this.getFunctionByName(currentCall.function);
let functionCode = compileCode(commonFunction, currentCall.parameters);
if (currentCallIndex !== calls.length - 1) {
functionCode = appendLine(functionCode);
}
compiledCodes.push(functionCode);
});
const scriptCode = merge(compiledCodes);
return new ScriptCode(script.name, scriptCode.code, scriptCode.revertCode);
}
private getFunctionByName(name: string): YamlFunction {
const func = this.functions.find((f) => f.name === name);
if (!func) {
throw new Error(`called function is not defined "${name}"`);
}
return func;
}
private ensureCompilable(call: ScriptFunctionCall) {
if (!this.functions || this.functions.length === 0) {
throw new Error('cannot compile without shared functions');
}
if (typeof call !== 'object') {
throw new Error('called function(s) must be an object');
}
}
}
function getDuplicates(texts: readonly string[]): string[] {
return texts.filter((item, index) => texts.indexOf(item) !== index);
}
function printList(list: readonly string[]): string {
return `"${list.join('","')}"`;
}
function ensureNoDuplicatesInFunctionNames(functions: readonly YamlFunction[]) {
const duplicateFunctionNames = getDuplicates(functions
.map((func) => func.name.toLowerCase()));
if (duplicateFunctionNames.length) {
throw new Error(`duplicate function name: ${printList(duplicateFunctionNames)}`);
}
}
function ensureNoDuplicatesInParameterNames(functions: readonly YamlFunction[]) {
const functionsWithParameters = functions
.filter((func) => func.parameters && func.parameters.length > 0);
for (const func of functionsWithParameters) {
const duplicateParameterNames = getDuplicates(func.parameters);
if (duplicateParameterNames.length) {
throw new Error(`"${func.name}": duplicate parameter name: ${printList(duplicateParameterNames)}`);
}
}
}
function ensureNoDuplicateCode(functions: readonly YamlFunction[]) {
const duplicateCodes = getDuplicates(functions.map((func) => func.code));
if (duplicateCodes.length > 0) {
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
}
const duplicateRevertCodes = getDuplicates(functions
.filter((func) => func.revertCode)
.map((func) => func.revertCode));
if (duplicateRevertCodes.length > 0) {
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
}
}
function ensureValidFunctions(functions: readonly YamlFunction[]) {
if (!functions) {
return;
}
ensureNoDuplicatesInFunctionNames(functions);
ensureNoDuplicatesInParameterNames(functions);
ensureNoDuplicateCode(functions);
}
function appendLine(code: ICompiledCode): ICompiledCode {
const appendLineIfNotEmpty = (str: string) => str ? `${str}\n` : str;
return {
code: appendLineIfNotEmpty(code.code),
revertCode: appendLineIfNotEmpty(code.revertCode),
};
}
function merge(codes: readonly ICompiledCode[]): ICompiledCode {
return {
code: codes.map((code) => code.code).join(''),
revertCode: codes.map((code) => code.revertCode).join(''),
};
}
function compileCode(func: YamlFunction, parameters: FunctionCallParameters): ICompiledCode {
return {
code: compileExpressions(func.code, parameters),
revertCode: compileExpressions(func.revertCode, parameters),
};
}
function compileExpressions(code: string, parameters: FunctionCallParameters): string {
let intermediateCode = compileToIL(code);
intermediateCode = substituteParameters(intermediateCode, parameters);
ensureNoExpressionLeft(intermediateCode);
return intermediateCode;
}
function substituteParameters(intermediateCode: string, parameters: FunctionCallParameters): string {
const parameterNames = getUniqueParameterNamesFromIL(intermediateCode);
if (parameterNames.length && !parameters) {
throw new Error(`no parameters defined, expected: ${printList(parameterNames)}`);
}
for (const parameterName of parameterNames) {
const parameterValue = parameters[parameterName];
intermediateCode = substituteParameter(intermediateCode, parameterName, parameterValue);
}
return intermediateCode;
}
function ensureValidCall(call: FunctionCall, scriptName: string) {
if (!call) {
throw new Error(`undefined function call in script "${scriptName}"`);
}
if (!call.function) {
throw new Error(`empty function name called in script "${scriptName}"`);
}
}
function getCallSequence(call: ScriptFunctionCall): FunctionCall[] {
if (call instanceof Array) {
return call as FunctionCall[];
}
return [ call as FunctionCall ];
}
function getDistinctValues(values: readonly string[]): string[] {
return values.filter((value, index, self) => {
return self.indexOf(value) === index;
});
}
// Trim each expression and put them inside "{{exp|}}" e.g. "{{ $hello }}" becomes "{{exp|$hello}}"
function compileToIL(code: string) {
return code.replace(/\{\{([\s]*[^;\s\{]+[\s]*)\}\}/g, (_, match) => {
return `\{\{exp|${match.trim()}\}\}`;
});
}
// Parses all distinct usages of {{exp|$parameterName}}
function getUniqueParameterNamesFromIL(ilCode: string) {
const allSubstitutions = ilCode.matchAll(/\{\{exp\|\$([^;\s\{]+[\s]*)\}\}/g);
const allParameters = Array.from(allSubstitutions, (match) => match[1]);
const uniqueParameterNames = getDistinctValues(allParameters);
return uniqueParameterNames;
}
// substitutes {{exp|$parameterName}} to value of the parameter
function substituteParameter(ilCode: string, parameterName: string, parameterValue: string) {
if (!parameterValue) {
throw Error(`parameter value is not provided for "${parameterName}" in function call`);
}
const pattern = `{{exp|$${parameterName}}}`;
return ilCode.split(pattern).join(parameterValue); // as .replaceAll() is not yet supported by TS
}
// finds all "{{exp|..}} left"
function ensureNoExpressionLeft(ilCode: string) {
const allSubstitutions = ilCode.matchAll(/\{\{exp\|(.*?)\}\}/g);
const allMatches = Array.from(allSubstitutions, (match) => match[1]);
const uniqueExpressions = getDistinctValues(allMatches);
if (uniqueExpressions.length > 0) {
throw new Error(`unknown expression: ${printList(uniqueExpressions)}`);
}
}

View File

@@ -2,15 +2,18 @@ import { Script } from '@/domain/Script';
import { YamlScript } from 'js-yaml-loader!./application.yaml';
import { parseDocUrls } from './DocumentationParser';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
import { IScriptCompiler } from './Compiler/IScriptCompiler';
import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
export function parseScript(yamlScript: YamlScript): Script {
if (!yamlScript) {
throw new Error('script is null or undefined');
export function parseScript(yamlScript: YamlScript, compiler: IScriptCompiler): Script {
validateScript(yamlScript);
if (!compiler) {
throw new Error('undefined compiler');
}
const script = new Script(
/* name */ yamlScript.name,
/* code */ yamlScript.code,
/* revertCode */ yamlScript.revertCode,
/* code */ parseCode(yamlScript, compiler),
/* docs */ parseDocUrls(yamlScript),
/* level */ getLevel(yamlScript.recommend));
return script;
@@ -30,3 +33,29 @@ function getLevel(level: string): RecommendationLevel | undefined {
}
return RecommendationLevel[typedLevel as keyof typeof RecommendationLevel];
}
function parseCode(yamlScript: YamlScript, compiler: IScriptCompiler): IScriptCode {
if (compiler.canCompile(yamlScript)) {
return compiler.compile(yamlScript);
}
return new ScriptCode(yamlScript.name, yamlScript.code, yamlScript.revertCode);
}
function ensureNotBothCallAndCode(yamlScript: YamlScript) {
if (yamlScript.code && yamlScript.call) {
throw new Error('cannot define both "call" and "code"');
}
if (yamlScript.revertCode && yamlScript.call) {
throw new Error('cannot define "revertCode" if "call" is defined');
}
}
function validateScript(yamlScript: YamlScript) {
if (!yamlScript) {
throw new Error('undefined script');
}
if (!yamlScript.code && !yamlScript.call) {
throw new Error('must define either "call" or "code"');
}
ensureNotBothCallAndCode(yamlScript);
}

View File

@@ -63,6 +63,6 @@ function appendSelection(
function appendCode(selection: SelectedScript, builder: CodeBuilder) {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const scriptCode = selection.revert ? selection.script.revertCode : selection.script.code;
const scriptCode = selection.revert ? selection.script.code.revert : selection.script.code.execute;
builder.appendFunction(name, scriptCode);
}

View File

@@ -23,7 +23,6 @@ export class UserFilter implements IUserFilter {
(script) => isScriptAMatch(script, filterLowercase));
const filteredCategories = this.application.getAllCategories().filter(
(category) => category.name.toLowerCase().includes(filterLowercase));
const matches = new FilterResult(
filteredScripts,
filteredCategories,
@@ -43,11 +42,11 @@ function isScriptAMatch(script: IScript, filterLowercase: string) {
if (script.name.toLowerCase().includes(filterLowercase)) {
return true;
}
if (script.code.toLowerCase().includes(filterLowercase)) {
if (script.code.execute.toLowerCase().includes(filterLowercase)) {
return true;
}
if (script.revertCode) {
return script.revertCode.toLowerCase().includes(filterLowercase);
if (script.code.revert) {
return script.code.revert.toLowerCase().includes(filterLowercase);
}
return false;
}

View File

@@ -1,16 +1,7 @@
declare module 'js-yaml-loader!*' {
export type CategoryOrScript = YamlCategory | YamlScript;
export type DocumentationUrls = ReadonlyArray<string> | string;
export interface YamlDocumentable {
docs?: DocumentationUrls;
}
export interface YamlScript extends YamlDocumentable {
name: string;
code: string;
revertCode: string;
recommend: string | undefined;
export interface ApplicationYaml {
actions: ReadonlyArray<YamlCategory>;
functions: ReadonlyArray<YamlFunction> | undefined;
}
export interface YamlCategory extends YamlDocumentable {
@@ -18,8 +9,37 @@ declare module 'js-yaml-loader!*' {
category: string;
}
export interface ApplicationYaml {
actions: ReadonlyArray<YamlCategory>;
export type CategoryOrScript = YamlCategory | YamlScript;
export type DocumentationUrls = ReadonlyArray<string> | string;
export interface YamlDocumentable {
docs?: DocumentationUrls;
}
export interface YamlFunction {
name: string;
code: string;
revertCode?: string;
parameters?: readonly string[];
}
export interface FunctionCallParameters {
[index: string]: string;
}
export interface FunctionCall {
function: string;
parameters?: FunctionCallParameters;
}
export type ScriptFunctionCall = readonly FunctionCall[] | FunctionCall | undefined;
export interface YamlScript extends YamlDocumentable {
name: string;
code: string | undefined;
revertCode: string | undefined;
call: ScriptFunctionCall;
recommend: string | undefined;
}
const content: ApplicationYaml;

View File

@@ -1,12 +1,12 @@
import { IEntity } from '../infrastructure/Entity/IEntity';
import { IDocumentable } from './IDocumentable';
import { RecommendationLevel } from './RecommendationLevel';
import { IScriptCode } from './IScriptCode';
export interface IScript extends IEntity<string>, IDocumentable {
readonly name: string;
readonly level?: RecommendationLevel;
readonly documentationUrls: ReadonlyArray<string>;
readonly code: string;
readonly revertCode: string;
readonly code: IScriptCode;
canRevert(): boolean;
}

View File

@@ -0,0 +1,4 @@
export interface IScriptCode {
readonly execute: string;
readonly revert: string;
}

View File

@@ -1,26 +1,22 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from './IScript';
import { RecommendationLevel } from './RecommendationLevel';
import { IScriptCode } from './IScriptCode';
export class Script extends BaseEntity<string> implements IScript {
constructor(
public readonly name: string,
public readonly code: string,
public readonly revertCode: string,
public readonly code: IScriptCode,
public readonly documentationUrls: ReadonlyArray<string>,
public readonly level?: RecommendationLevel) {
super(name);
validateCode(name, code);
if (!code) {
throw new Error(`undefined code (script: ${name})`);
}
validateLevel(level);
if (revertCode) {
validateCode(name, revertCode);
if (code === revertCode) {
throw new Error(`${name}: Code itself and its reverting code cannot be the same`);
}
}
}
public canRevert(): boolean {
return Boolean(this.revertCode);
return Boolean(this.code.revert);
}
}
@@ -29,40 +25,3 @@ function validateLevel(level?: RecommendationLevel) {
throw new Error(`invalid level: ${level}`);
}
}
function validateCode(name: string, code: string): void {
if (!code || code.length === 0) {
throw new Error(`Code of ${name} is empty or null`);
}
ensureCodeHasUniqueLines(name, code);
ensureNoEmptyLines(name, code);
}
function ensureNoEmptyLines(name: string, code: string): void {
if (code.split('\n').some((line) => line.trim().length === 0)) {
throw Error(`Script has empty lines "${name}"`);
}
}
function mayBeUniqueLine(codeLine: string): boolean {
const trimmed = codeLine.trim();
if (trimmed === ')' || trimmed === '(') { // "(" and ")" are used often in batch code
return false;
}
if (codeLine.startsWith(':: ') || codeLine.startsWith('REM ')) { // Is comment?
return false;
}
return true;
}
function ensureCodeHasUniqueLines(name: string, code: string): void {
const lines = code.split('\n')
.filter((line) => mayBeUniqueLine(line));
if (lines.length === 0) {
return;
}
const duplicateLines = lines.filter((e, i, a) => a.indexOf(e) !== i);
if (duplicateLines.length !== 0) {
throw Error(`Duplicates detected in script "${name}":\n ${duplicateLines.join('\n')}`);
}
}

57
src/domain/ScriptCode.ts Normal file
View File

@@ -0,0 +1,57 @@
import { IScriptCode } from './IScriptCode';
export class ScriptCode implements IScriptCode {
constructor(
scriptName: string,
public readonly execute: string,
public readonly revert: string) {
if (!scriptName) {
throw new Error('script name is undefined');
}
validateCode(scriptName, execute);
if (revert) {
scriptName = `${scriptName} (revert)`;
validateCode(scriptName, revert);
if (execute === revert) {
throw new Error(`${scriptName}: Code itself and its reverting code cannot be the same`);
}
}
}
}
function validateCode(name: string, code: string): void {
if (!code || code.length === 0) {
throw new Error(`code of ${name} is empty or undefined`);
}
ensureCodeHasUniqueLines(name, code);
ensureNoEmptyLines(name, code);
}
function ensureNoEmptyLines(name: string, code: string): void {
if (code.split('\n').some((line) => line.trim().length === 0)) {
throw Error(`Script has empty lines "${name}"`);
}
}
function ensureCodeHasUniqueLines(name: string, code: string): void {
const lines = code.split('\n')
.filter((line) => mayBeUniqueLine(line));
if (lines.length === 0) {
return;
}
const duplicateLines = lines.filter((e, i, a) => a.indexOf(e) !== i);
if (duplicateLines.length !== 0) {
throw Error(`Duplicates detected in script "${name}":\n ${duplicateLines.join('\n')}`);
}
}
function mayBeUniqueLine(codeLine: string): boolean {
const trimmed = codeLine.trim();
if (trimmed === ')' || trimmed === '(') { // "(" and ")" are used often in batch code
return false;
}
if (codeLine.startsWith(':: ') || codeLine.startsWith('REM ')) { // Is comment?
return false;
}
return true;
}

View File

@@ -5,22 +5,41 @@ import 'mocha';
import { expect } from 'chai';
import { parseCategory } from '@/application/Parser/CategoryParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub';
describe('ApplicationParser', () => {
describe('parseApplication', () => {
it('can parse current application file', () => {
expect(() => parseApplication(applicationFile)).to.not.throw();
// act
const act = () => parseApplication(applicationFile);
// assert
expect(act).to.not.throw();
});
it('throws when undefined', () => {
expect(() => parseApplication(undefined)).to.throw('application is null or undefined');
// arrange
const expectedError = 'application is null or undefined';
// act
const act = () => parseApplication(undefined);
// assert
expect(act).to.throw(expectedError);
});
it('throws when undefined actions', () => {
const sut: ApplicationYaml = { actions: undefined };
expect(() => parseApplication(sut)).to.throw('application does not define any action');
// arrange
const sut: ApplicationYaml = { actions: undefined, functions: undefined };
const expectedError = 'application does not define any action';
// act
const act = () => parseApplication(sut);
// assert
expect(act).to.throw(expectedError);
});
it('throws when has no actions', () => {
const sut: ApplicationYaml = { actions: [] };
expect(() => parseApplication(sut)).to.throw('application does not define any action');
// arrange
const sut: ApplicationYaml = { actions: [], functions: undefined };
const expectedError = 'application does not define any action';
// act
const act = () => parseApplication(sut);
// assert
expect(act).to.throw(expectedError);
});
describe('information', () => {
it('returns expected repository version', () => {
@@ -28,7 +47,7 @@ describe('ApplicationParser', () => {
const expected = 'expected-version';
const env = getProcessEnvironmentStub();
env.VUE_APP_VERSION = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.version;
// assert
@@ -39,7 +58,7 @@ describe('ApplicationParser', () => {
const expected = 'https://expected-repository.url';
const env = getProcessEnvironmentStub();
env.VUE_APP_REPOSITORY_URL = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.repositoryUrl;
// assert
@@ -50,7 +69,7 @@ describe('ApplicationParser', () => {
const expected = 'expected-app-name';
const env = getProcessEnvironmentStub();
env.VUE_APP_NAME = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.name;
// assert
@@ -61,7 +80,7 @@ describe('ApplicationParser', () => {
const expected = 'https://expected.sexy';
const env = getProcessEnvironmentStub();
env.VUE_APP_HOMEPAGE_URL = expected;
const sut: ApplicationYaml = { actions: [ getTestCategory() ] };
const sut: ApplicationYaml = { actions: [ getTestCategory() ], functions: undefined };
// act
const actual = parseApplication(sut, env).info.homepage;
// assert
@@ -71,8 +90,9 @@ describe('ApplicationParser', () => {
it('parses actions', () => {
// arrange
const actions = [ getTestCategory('test1'), getTestCategory('test2') ];
const expected = [ parseCategory(actions[0]), parseCategory(actions[1]) ];
const sut: ApplicationYaml = { actions };
const compiler = new ScriptCompilerStub();
const expected = [ parseCategory(actions[0], compiler), parseCategory(actions[1], compiler) ];
const sut: ApplicationYaml = { actions, functions: undefined };
// act
const actual = parseApplication(sut).actions;
// assert
@@ -103,6 +123,7 @@ function getTestScript(scriptName: string, level: RecommendationLevel = Recommen
code: 'script code',
revertCode: 'revert code',
recommend: RecommendationLevel[level].toLowerCase(),
call: undefined,
};
}

View File

@@ -4,88 +4,150 @@ import { parseCategory } from '@/application/Parser/CategoryParser';
import { YamlCategory, CategoryOrScript, YamlScript } from 'js-yaml-loader!./application.yaml';
import { parseScript } from '@/application/Parser/ScriptParser';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub';
import { YamlScriptStub } from '../../stubs/YamlScriptStub';
describe('CategoryParser', () => {
describe('parseCategory', () => {
describe('invalid category', () => {
it('throws when undefined', () => {
expect(() => parseCategory(undefined)).to.throw('category is null or undefined');
// arrange
const expectedMessage = 'category is null or undefined';
const category = undefined;
const compiler = new ScriptCompilerStub();
// act
const act = () => parseCategory(category, compiler);
// assert
expect(act).to.throw(expectedMessage);
});
it('throws when children is empty', () => {
it('throws when children are empty', () => {
// arrange
const expectedMessage = 'category has no children';
const category: YamlCategory = {
category: 'test',
children: [],
};
expect(() => parseCategory(category)).to.throw('category has no children');
const compiler = new ScriptCompilerStub();
// act
const act = () => parseCategory(category, compiler);
// assert
expect(act).to.throw(expectedMessage);
});
it('throws when children is undefined', () => {
it('throws when children are undefined', () => {
// arrange
const expectedMessage = 'category has no children';
const category: YamlCategory = {
category: 'test',
children: undefined,
};
expect(() => parseCategory(category)).to.throw('category has no children');
const compiler = new ScriptCompilerStub();
// act
const act = () => parseCategory(category, compiler);
// assert
expect(act).to.throw(expectedMessage);
});
it('throws when name is empty', () => {
it('throws when name is empty or undefined', () => {
// arrange
const expectedMessage = 'category has no name';
const invalidNames = ['', undefined];
invalidNames.forEach((invalidName) => {
const category: YamlCategory = {
category: '',
category: invalidName,
children: getTestChildren(),
};
expect(() => parseCategory(category)).to.throw('category has no name');
const compiler = new ScriptCompilerStub();
// act
const act = () => parseCategory(category, compiler);
// assert
expect(act).to.throw(expectedMessage);
});
it('throws when name is undefined', () => {
const category: YamlCategory = {
category: undefined,
children: getTestChildren(),
};
expect(() => parseCategory(category)).to.throw('category has no name');
});
});
it('throws when compiler is undefined', () => {
// arrange
const expectedError = 'undefined compiler';
const compiler = undefined;
const category = getValidCategory();
// act
const act = () => parseCategory(category, compiler);
// assert
expect(act).to.throw(expectedError);
});
it('returns expected docs', () => {
// arrange
const url = 'https://privacy.sexy';
const expected = parseDocUrls({ docs: url });
const compiler = new ScriptCompilerStub();
const category: YamlCategory = {
category: 'category name',
children: getTestChildren(),
docs: url,
};
// act
const actual = parseCategory(category).documentationUrls;
const actual = parseCategory(category, compiler).documentationUrls;
// assert
expect(actual).to.deep.equal(expected);
});
it('returns expected scripts', () => {
describe('parses expected subscript', () => {
it('single script with code', () => {
// arrange
const script = getTestScript();
const expected = [ parseScript(script) ];
const script = YamlScriptStub.createWithCode();
const compiler = new ScriptCompilerStub();
const expected = [ parseScript(script, compiler) ];
const category: YamlCategory = {
category: 'category name',
children: [ script ],
};
// act
const actual = parseCategory(category).scripts;
const actual = parseCategory(category, compiler).scripts;
// assert
expect(actual).to.deep.equal(expected);
});
it('single script with function call', () => {
// arrange
const script = YamlScriptStub.createWithCall();
const compiler = new ScriptCompilerStub()
.withCompileAbility(script);
const expected = [ parseScript(script, compiler) ];
const category: YamlCategory = {
category: 'category name',
children: [ script ],
};
// act
const actual = parseCategory(category, compiler).scripts;
// assert
expect(actual).to.deep.equal(expected);
});
it('multiple scripts with function call and code', () => {
// arrange
const callableScript = YamlScriptStub.createWithCall();
const scripts = [ callableScript, YamlScriptStub.createWithCode() ];
const compiler = new ScriptCompilerStub()
.withCompileAbility(callableScript);
const expected = scripts.map((script) => parseScript(script, compiler));
const category: YamlCategory = {
category: 'category name',
children: scripts,
};
// act
const actual = parseCategory(category, compiler).scripts;
// assert
expect(actual).to.deep.equal(expected);
});
});
it('returns expected subcategories', () => {
// arrange
const expected: YamlCategory[] = [ {
category: 'test category',
children: [ getTestScript() ],
children: [ YamlScriptStub.createWithCode() ],
}];
const category: YamlCategory = {
category: 'category name',
children: expected,
};
const compiler = new ScriptCompilerStub();
// act
const actual = parseCategory(category).subCategories;
const actual = parseCategory(category, compiler).subCategories;
// assert
expect(actual).to.have.lengthOf(1);
expect(actual[0].name).to.equal(expected[0].category);
@@ -94,17 +156,16 @@ describe('CategoryParser', () => {
});
});
function getTestChildren(): ReadonlyArray<CategoryOrScript> {
return [
getTestScript(),
];
}
function getTestScript(): YamlScript {
function getValidCategory(): YamlCategory {
return {
name: 'script name',
code: 'script code',
revertCode: 'revert code',
recommend: RecommendationLevel[RecommendationLevel.Standard],
category: 'category name',
children: getTestChildren(),
docs: undefined,
};
}
function getTestChildren(): ReadonlyArray<CategoryOrScript> {
return [
YamlScriptStub.createWithCode(),
];
}

View File

@@ -0,0 +1,363 @@
import 'mocha';
import { expect } from 'chai';
import { ScriptCompiler } from '@/application/Parser/Compiler/ScriptCompiler';
import { YamlScriptStub } from '../../../stubs/YamlScriptStub';
import { YamlFunction, YamlScript, FunctionCall, ScriptFunctionCall, FunctionCallParameters } from 'js-yaml-loader!./application.yaml';
import { IScriptCode } from '@/domain/IScriptCode';
import { IScriptCompiler } from '@/application/Parser/Compiler/IScriptCompiler';
describe('ScriptCompiler', () => {
describe('ctor', () => {
it('throws when functions have same names', () => {
// arrange
const expectedError = `duplicate function name: "same-func-name"`;
const functions: YamlFunction[] = [ {
name: 'same-func-name',
code: 'non-empty-code',
}, {
name: 'same-func-name',
code: 'non-empty-code-2',
}];
// act
const act = () => new ScriptCompiler(functions);
// assert
expect(act).to.throw(expectedError);
});
it('throws when function parameters have same names', () => {
// arrange
const func: YamlFunction = {
name: 'function-name',
code: 'non-empty-code',
parameters: [ 'duplicate', 'duplicate' ],
};
const expectedError = `"${func.name}": duplicate parameter name: "duplicate"`;
// act
const act = () => new ScriptCompiler([func]);
// assert
expect(act).to.throw(expectedError);
});
describe('throws when when function have duplicate code', () => {
it('code', () => {
// arrange
const expectedError = `duplicate "code" in functions: "duplicate-code"`;
const functions: YamlFunction[] = [ {
name: 'func-1',
code: 'duplicate-code',
}, {
name: 'func-2',
code: 'duplicate-code',
}];
// act
const act = () => new ScriptCompiler(functions);
// assert
expect(act).to.throw(expectedError);
});
it('revertCode', () => {
// arrange
const expectedError = `duplicate "revertCode" in functions: "duplicate-revert-code"`;
const functions: YamlFunction[] = [ {
name: 'func-1',
code: 'code-1',
revertCode: 'duplicate-revert-code',
}, {
name: 'func-2',
code: 'code-2',
revertCode: 'duplicate-revert-code',
}];
// act
const act = () => new ScriptCompiler(functions);
// assert
expect(act).to.throw(expectedError);
});
});
});
describe('canCompile', () => {
it('returns true if "call" is defined', () => {
// arrange
const sut = new ScriptCompiler([]);
const script = YamlScriptStub.createWithCall();
// act
const actual = sut.canCompile(script);
// assert
expect(actual).to.equal(true);
});
it('returns false if "call" is undefined', () => {
// arrange
const sut = new ScriptCompiler([]);
const script = YamlScriptStub.createWithCode();
// act
const actual = sut.canCompile(script);
// assert
expect(actual).to.equal(false);
});
});
describe('compile', () => {
describe('invalid state', () => {
it('throws if functions are empty', () => {
// arrange
const expectedError = 'cannot compile without shared functions';
const functions = [];
const sut = new ScriptCompiler(functions);
const script = YamlScriptStub.createWithCall();
// act
const act = () => sut.compile(script);
// assert
expect(act).to.throw(expectedError);
});
it('throws if call is not an object', () => {
// arrange
const expectedError = 'called function(s) must be an object';
const invalidValues = [undefined, 'string', 33];
const sut = new ScriptCompiler(createFunctions());
invalidValues.forEach((invalidValue) => {
const script = YamlScriptStub.createWithoutCallOrCodes() // because call ctor overwrites "undefined"
.withCall(invalidValue as any);
// act
const act = () => sut.compile(script);
// assert
expect(act).to.throw(expectedError);
});
});
describe('invalid function reference', () => {
it('throws if function does not exist', () => {
// arrange
const sut = new ScriptCompiler(createFunctions());
const nonExistingFunctionName = 'non-existing-func';
const expectedError = `called function is not defined "${nonExistingFunctionName}"`;
const call: ScriptFunctionCall = { function: nonExistingFunctionName };
const script = YamlScriptStub.createWithCall(call);
// act
const act = () => sut.compile(script);
// assert
expect(act).to.throw(expectedError);
});
it('throws if function is undefined', () => {
// arrange
const existingFunctionName = 'existing-func';
const sut = new ScriptCompiler(createFunctions(existingFunctionName));
const call: ScriptFunctionCall = [
{ function: existingFunctionName },
undefined,
];
const script = YamlScriptStub.createWithCall(call);
const expectedError = `undefined function call in script "${script.name}"`;
// act
const act = () => sut.compile(script);
// assert
expect(act).to.throw(expectedError);
});
it('throws if function name is not given', () => {
// arrange
const existingFunctionName = 'existing-func';
const sut = new ScriptCompiler(createFunctions(existingFunctionName));
const call: FunctionCall[] = [
{ function: existingFunctionName },
{ function: undefined }];
const script = YamlScriptStub.createWithCall(call);
const expectedError = `empty function name called in script "${script.name}"`;
// act
const act = () => sut.compile(script);
// assert
expect(act).to.throw(expectedError);
});
});
});
describe('builds code as expected', () => {
it('builds single call as expected', () => {
// arrange
const functionName = 'testSharedFunction';
const expected: IScriptCode = {
execute: 'expected-code',
revert: 'expected-revert-code',
};
const func: YamlFunction = {
name: functionName,
parameters: [],
code: expected.execute,
revertCode: expected.revert,
};
const sut = new ScriptCompiler([func]);
const call: FunctionCall = { function: functionName };
const script = YamlScriptStub.createWithCall(call);
// act
const actual = sut.compile(script);
// assert
expect(actual).to.deep.equal(expected);
});
it('builds call sequence as expected', () => {
// arrange
const firstFunction: YamlFunction = {
name: 'first-function-name',
parameters: [],
code: 'first-function-code',
revertCode: 'first-function-revert-code',
};
const secondFunction: YamlFunction = {
name: 'second-function-name',
parameters: [],
code: 'second-function-code',
revertCode: 'second-function-revert-code',
};
const expected: IScriptCode = {
execute: 'first-function-code\nsecond-function-code',
revert: 'first-function-revert-code\nsecond-function-revert-code',
};
const sut = new ScriptCompiler([firstFunction, secondFunction]);
const call: FunctionCall[] = [
{ function: firstFunction.name },
{ function: secondFunction.name },
];
const script = YamlScriptStub.createWithCall(call);
// act
const actual = sut.compile(script);
// assert
expect(actual).to.deep.equal(expected);
});
});
describe('parameter substitution', () => {
describe('substitutes by ignoring whitespaces inside mustaches', () => {
// arrange
const mustacheVariations = [
'Hello {{ $test }}!',
'Hello {{$test }}!',
'Hello {{ $test}}!',
'Hello {{$test}}!'];
mustacheVariations.forEach((variation) => {
it(variation, () => {
// arrange
const env = new TestEnvironment({
code: variation,
parameters: {
test: 'world',
},
});
const expected = env.expect('Hello world!');
// act
const actual = env.sut.compile(env.script);
// assert
expect(actual).to.deep.equal(expected);
});
});
});
describe('substitutes as expected', () => {
it('with different parameters', () => {
// arrange
const env = new TestEnvironment({
code: 'He{{ $firstParameter }} {{ $secondParameter }}!',
parameters: {
firstParameter: 'llo',
secondParameter: 'world',
},
});
const expected = env.expect('Hello world!');
// act
const actual = env.sut.compile(env.script);
// assert
expect(actual).to.deep.equal(expected);
});
it('with same parameter repeated', () => {
// arrange
const env = new TestEnvironment({
code: '{{ $parameter }} {{ $parameter }}!',
parameters: {
parameter: 'Hodor',
},
});
const expected = env.expect('Hodor Hodor!');
// act
const actual = env.sut.compile(env.script);
// assert
expect(actual).to.deep.equal(expected);
});
});
it('throws when parameters is undefined', () => {
// arrange
const env = new TestEnvironment({
code: '{{ $parameter }} {{ $parameter }}!',
});
const expectedError = 'no parameters defined, expected: "parameter"';
// act
const act = () => env.sut.compile(env.script);
// assert
expect(act).to.throw(expectedError);
});
it('throws when parameter value is not provided', () => {
// arrange
const env = new TestEnvironment({
code: '{{ $parameter }} {{ $parameter }}!',
parameters: {
parameter: undefined,
},
});
const expectedError = 'parameter value is not provided for "parameter" in function call';
// act
const act = () => env.sut.compile(env.script);
// assert
expect(act).to.throw(expectedError);
});
it('throws on unknown expressions', () => {
// arrange
const env = new TestEnvironment({
code: '{{ each }}',
parameters: {
parameter: undefined,
},
});
const expectedError = 'unknown expression: "each"';
// act
const act = () => env.sut.compile(env.script);
// assert
expect(act).to.throw(expectedError);
});
});
});
});
interface ITestCase {
code: string;
parameters?: FunctionCallParameters;
}
class TestEnvironment {
public readonly sut: IScriptCompiler;
public readonly script: YamlScript;
constructor(testCase: ITestCase) {
const functionName = 'testFunction';
const func: YamlFunction = {
name: functionName,
parameters: testCase.parameters ? Object.keys(testCase.parameters) : undefined,
code: this.getCode(testCase.code, 'execute'),
revertCode: this.getCode(testCase.code, 'revert'),
};
this.sut = new ScriptCompiler([func]);
const call: FunctionCall = {
function: functionName,
parameters: testCase.parameters,
};
this.script = YamlScriptStub.createWithCall(call);
}
public expect(code: string): IScriptCode {
return {
execute: this.getCode(code, 'execute'),
revert: this.getCode(code, 'revert'),
};
}
private getCode(text: string, type: 'execute' | 'revert'): string {
return `${text} (${type})`;
}
}
function createFunctions(...names: string[]): YamlFunction[] {
if (!names || names.length === 0) {
names = ['test-function'];
}
return names.map((functionName) => {
const func: YamlFunction = {
name: functionName,
parameters: [],
code: `REM test-code (${functionName})`,
revertCode: `REM test-revert-code (${functionName})`,
};
return func;
});
}

View File

@@ -1,58 +1,93 @@
import { YamlScript } from 'js-yaml-loader!./application.yaml';
import 'mocha';
import { expect } from 'chai';
import { parseScript } from '@/application/Parser/ScriptParser';
import { parseDocUrls } from '@/application/Parser/DocumentationParser';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
import { ScriptCode } from '@/domain/ScriptCode';
import { ScriptCompilerStub } from '../../stubs/ScriptCompilerStub';
import { YamlScriptStub } from '../../stubs/YamlScriptStub';
describe('ScriptParser', () => {
describe('parseScript', () => {
it('parses name as expected', () => {
// arrange
const script = getValidScript();
script.name = 'expected-name';
const expected = 'test-expected-name';
const script = YamlScriptStub.createWithCode()
.withName(expected);
const compiler = new ScriptCompilerStub();
// act
const actual = parseScript(script);
const actual = parseScript(script, compiler);
// assert
expect(actual.name).to.equal(script.name);
});
it('parses code as expected', () => {
// arrange
const script = getValidScript();
script.code = 'expected-code';
// act
const actual = parseScript(script);
// assert
expect(actual.code).to.equal(script.code);
});
it('parses revertCode as expected', () => {
// arrange
const script = getValidScript();
script.code = 'expected-code';
// act
const actual = parseScript(script);
// assert
expect(actual.revertCode).to.equal(script.revertCode);
expect(actual.name).to.equal(expected);
});
it('parses docs as expected', () => {
// arrange
const script = getValidScript();
script.docs = [ 'https://expected-doc1.com', 'https://expected-doc2.com' ];
const docs = [ 'https://expected-doc1.com', 'https://expected-doc2.com' ];
const script = YamlScriptStub.createWithCode()
.withDocs(docs);
const compiler = new ScriptCompilerStub();
const expected = parseDocUrls(script);
// act
const actual = parseScript(script);
const actual = parseScript(script, compiler);
// assert
expect(actual.documentationUrls).to.deep.equal(expected);
});
describe('invalid script', () => {
it('throws when script is undefined', () => {
// arrange
const expectedError = 'undefined script';
const compiler = new ScriptCompilerStub();
const script = undefined;
// act
const act = () => parseScript(script, compiler);
// assert
expect(act).to.throw(expectedError);
});
it('throws when both function call and code are defined', () => {
// arrange
const expectedError = 'cannot define both "call" and "code"';
const compiler = new ScriptCompilerStub();
const script = YamlScriptStub
.createWithCall()
.withCode('code');
// act
const act = () => parseScript(script, compiler);
// assert
expect(act).to.throw(expectedError);
});
it('throws when both function call and revertCode are defined', () => {
// arrange
const expectedError = 'cannot define "revertCode" if "call" is defined';
const compiler = new ScriptCompilerStub();
const script = YamlScriptStub
.createWithCall()
.withRevertCode('revert-code');
// act
const act = () => parseScript(script, compiler);
// assert
expect(act).to.throw(expectedError);
});
it('throws when neither call or revertCode are defined', () => {
// arrange
const expectedError = 'must define either "call" or "code"';
const compiler = new ScriptCompilerStub();
const script = YamlScriptStub.createWithoutCallOrCodes();
// act
const act = () => parseScript(script, compiler);
// assert
expect(act).to.throw(expectedError);
});
});
describe('level', () => {
it('accepts undefined level', () => {
const undefinedLevels: string[] = [ '', undefined ];
undefinedLevels.forEach((undefinedLevel) => {
// arrange
const script = getValidScript();
const compiler = new ScriptCompilerStub();
const script = YamlScriptStub.createWithCode();
script.recommend = undefinedLevel;
// act
const actual = parseScript(script);
const actual = parseScript(script, compiler);
// assert
expect(actual.level).to.equal(undefined);
});
@@ -60,10 +95,11 @@ describe('ScriptParser', () => {
it('throws on unknown level', () => {
// arrange
const unknownLevel = 'boi';
const script = getValidScript();
const compiler = new ScriptCompilerStub();
const script = YamlScriptStub.createWithCode();
script.recommend = unknownLevel;
// act
const act = () => parseScript(script);
const act = () => parseScript(script, compiler);
// assert
expect(act).to.throw(`unknown level: "${unknownLevel}"`);
});
@@ -71,10 +107,11 @@ describe('ScriptParser', () => {
const nonStringTypes: any[] = [ 5, true ];
nonStringTypes.forEach((nonStringType) => {
// arrange
const script = getValidScript();
const script = YamlScriptStub.createWithCode();
const compiler = new ScriptCompilerStub();
script.recommend = nonStringType;
// act
const act = () => parseScript(script);
const act = () => parseScript(script, compiler);
// assert
expect(act).to.throw(`level must be a string but it was ${typeof nonStringType}`);
});
@@ -84,10 +121,11 @@ describe('ScriptParser', () => {
it(levelText, () => {
// arrange
const expectedLevel = RecommendationLevel[levelText];
const script = getValidScript();
const script = YamlScriptStub.createWithCode();
const compiler = new ScriptCompilerStub();
script.recommend = levelText;
// act
const actual = parseScript(script);
const actual = parseScript(script, compiler);
// assert
expect(actual.level).to.equal(expectedLevel);
});
@@ -95,24 +133,66 @@ describe('ScriptParser', () => {
});
it('parses level case insensitive', () => {
// arrange
const script = getValidScript();
const script = YamlScriptStub.createWithCode();
const compiler = new ScriptCompilerStub();
const expected = RecommendationLevel.Standard;
script.recommend = RecommendationLevel[expected].toUpperCase();
// act
const actual = parseScript(script);
const actual = parseScript(script, compiler);
// assert
expect(actual.level).to.equal(expected);
});
});
describe('code', () => {
it('parses code as expected', () => {
// arrange
const expected = 'expected-code';
const script = YamlScriptStub
.createWithCode()
.withCode(expected);
const compiler = new ScriptCompilerStub();
// act
const parsed = parseScript(script, compiler);
// assert
const actual = parsed.code.execute;
expect(actual).to.equal(expected);
});
it('parses revertCode as expected', () => {
// arrange
const expected = 'expected-revert-code';
const script = YamlScriptStub
.createWithCode()
.withRevertCode(expected);
const compiler = new ScriptCompilerStub();
// act
const parsed = parseScript(script, compiler);
// assert
const actual = parsed.code.revert;
expect(actual).to.equal(expected);
});
describe('compiler', () => {
it('throws when compiler is not defined', () => {
// arrange
const script = YamlScriptStub.createWithCode();
const compiler = undefined;
// act
const act = () => parseScript(script, compiler);
// assert
expect(act).to.throw('undefined compiler');
});
it('gets code from compiler', () => {
// arrange
const expected = new ScriptCode('test-script', 'code', 'revert-code');
const script = YamlScriptStub.createWithCode();
const compiler = new ScriptCompilerStub()
.withCompileAbility(script, expected);
// act
const parsed = parseScript(script, compiler);
// assert
const actual = parsed.code;
expect(actual).to.equal(expected);
});
});
});
});
});
function getValidScript(): YamlScript {
return {
name: 'valid-name',
code: 'valid-code',
revertCode: 'expected revert code',
docs: ['hello.com'],
recommend: RecommendationLevel[RecommendationLevel.Standard].toLowerCase(),
};
}

View File

@@ -18,10 +18,11 @@ describe('UserFilter', () => {
// assert
expect(isCalled).to.be.equal(true);
});
it('currentFilter is undefined', () => {
it('sets currentFilter to undefined', () => {
// arrange
const sut = new UserFilter(new ApplicationStub());
// act
sut.setFilter('non-important');
sut.removeFilter();
// assert
expect(sut.currentFilter).to.be.equal(undefined);
@@ -51,6 +52,7 @@ describe('UserFilter', () => {
expect(actual.hasAnyMatches()).be.equal(false);
expect(actual.query).to.equal(nonMatchingFilter);
});
describe('signals when matches', () => {
describe('signals when script matches', () => {
it('code matches', () => {
// arrange
@@ -112,7 +114,7 @@ describe('UserFilter', () => {
expect(actual.query).to.equal(filter);
expect(sut.currentFilter).to.deep.equal(actual);
});
});
it('signals when category matches', () => {
// arrange
const categoryName = 'HELLO world';

View File

@@ -1,76 +1,149 @@
import 'mocha';
import { expect } from 'chai';
import { Script } from '@/domain/Script';
import { RecommendationLevelNames, RecommendationLevel } from '@/domain/RecommendationLevel';
import { RecommendationLevel, RecommendationLevels } from '@/domain/RecommendationLevel';
import { ScriptCode } from '@/domain/ScriptCode';
import { IScriptCode } from '@/domain/IScriptCode';
describe('Script', () => {
describe('ctor', () => {
describe('code', () => {
it('cannot construct with duplicate lines', () => {
const code = 'duplicate\nduplicate\ntest\nduplicate';
expect(() => createWithCode(code)).to.throw();
});
it('cannot construct with empty lines', () => {
const code = 'duplicate\n\n\ntest\nduplicate';
expect(() => createWithCode(code)).to.throw();
});
describe('scriptCode', () => {
it('sets as expected', () => {
const expected = 'expected-revert';
const sut = createWithCode(expected);
expect(sut.code).to.equal(expected);
// arrange
const name = 'test-script';
const expected = new ScriptCode(name, 'expected-execute', 'expected-revert');
const sut = new ScriptBuilder()
.withCode(expected)
.build();
// act
const actual = sut.code;
// assert
expect(actual).to.deep.equal(expected);
});
});
describe('revertCode', () => {
it('cannot construct with duplicate lines', () => {
const code = 'duplicate\nduplicate\ntest\nduplicate';
expect(() => createWithCode('REM', code)).to.throw();
});
it('cannot construct with empty lines', () => {
const code = 'duplicate\n\n\ntest\nduplicate';
expect(() => createWithCode('REM', code)).to.throw();
});
it('cannot construct with when same as code', () => {
const code = 'REM';
expect(() => createWithCode(code, code)).to.throw();
});
it('sets as expected', () => {
const expected = 'expected-revert';
const sut = createWithCode('abc', expected);
expect(sut.revertCode).to.equal(expected);
it('throws if undefined', () => {
// arrange
const name = 'script-name';
const expectedError = `undefined code (script: ${name})`;
const code: IScriptCode = undefined;
// act
const construct = () => new ScriptBuilder()
.withName(name)
.withCode(code)
.build();
// assert
expect(construct).to.throw(expectedError);
});
});
describe('canRevert', () => {
it('returns false without revert code', () => {
const sut = createWithCode('code');
expect(sut.canRevert()).to.equal(false);
// arrange
const sut = new ScriptBuilder()
.withCodes('code')
.build();
// act
const actual = sut.canRevert();
// assert
expect(actual).to.equal(false);
});
it('returns true with revert code', () => {
const sut = createWithCode('code', 'non empty revert code');
expect(sut.canRevert()).to.equal(true);
// arrange
const sut = new ScriptBuilder()
.withCodes('code', 'non empty revert code')
.build();
// act
const actual = sut.canRevert();
// assert
expect(actual).to.equal(true);
});
});
describe('level', () => {
it('cannot construct with invalid wrong value', () => {
expect(() => createWithLevel(55)).to.throw('invalid level');
// arrange
const invalidValue: RecommendationLevel = 55;
const expectedError = 'invalid level';
// act
const construct = () => new ScriptBuilder()
.withRecommendationLevel(invalidValue)
.build();
// assert
expect(construct).to.throw(expectedError);
});
it('sets undefined as expected', () => {
const sut = createWithLevel(undefined);
expect(sut.level).to.equal(undefined);
// arrange
const expected = undefined;
// act
const sut = new ScriptBuilder()
.withRecommendationLevel(expected)
.build();
// assert
expect(sut.level).to.equal(expected);
});
it('sets as expected', () => {
for (const expected of RecommendationLevelNames) {
const sut = createWithLevel(RecommendationLevel[expected]);
const actual = RecommendationLevel[sut.level];
// arrange
for (const expected of RecommendationLevels) {
// act
const sut = new ScriptBuilder()
.withRecommendationLevel(expected)
.build();
// assert
const actual = sut.level;
expect(actual).to.equal(expected);
}
});
});
describe('documentationUrls', () => {
it('sets as expected', () => {
// arrange
const expected = [ 'doc1', 'doc2 '];
// act
const sut = new ScriptBuilder()
.withDocumentationUrls(expected)
.build();
const actual = sut.documentationUrls;
// assert
expect(actual).to.equal(expected);
});
});
});
});
function createWithCode(code: string, revertCode?: string): Script {
return new Script('name', code, revertCode, [], RecommendationLevel.Standard);
}
function createWithLevel(level: RecommendationLevel): Script {
return new Script('name', 'code', 'revertCode', [], level);
class ScriptBuilder {
private name = 'test-script';
private code: IScriptCode = new ScriptCode(this.name, 'code', 'revert-code');
private level = RecommendationLevel.Standard;
private documentationUrls: readonly string[] = undefined;
public withCodes(code: string, revertCode = ''): ScriptBuilder {
this.code = new ScriptCode(this.name, code, revertCode);
return this;
}
public withCode(code: IScriptCode): ScriptBuilder {
this.code = code;
return this;
}
public withName(name: string): ScriptBuilder {
this.name = name;
return this;
}
public withRecommendationLevel(level: RecommendationLevel): ScriptBuilder {
this.level = level;
return this;
}
public withDocumentationUrls(urls: readonly string[]): ScriptBuilder {
this.documentationUrls = urls;
return this;
}
public build(): Script {
return new Script(
this.name,
this.code,
this.documentationUrls,
this.level,
);
}
}

View File

@@ -0,0 +1,93 @@
import 'mocha';
import { expect } from 'chai';
import { ScriptCode } from '@/domain/ScriptCode';
describe('ScriptCode', () => {
describe('scriptName', () => {
it('throws if undefined', () => {
// arrange
const expectedError = 'name is undefined';
const name = undefined;
// act
const act = () => new ScriptCode(name, 'non-empty-code', '');
// assert
expect(act).to.throw(expectedError);
});
});
describe('code', () => {
it('cannot construct with duplicate lines', () => {
// arrange
const code = 'duplicate\nduplicate\ntest\nduplicate';
// act
const act = () => createSut(code);
// assert
expect(act).to.throw();
});
it('cannot construct with empty lines', () => {
// arrange
const code = 'line1\n\n\nline2';
// act
const act = () => createSut(code);
// assert
expect(act).to.throw();
});
it('cannot construct with empty or undefined values', () => {
// arrange
const name = 'test-code';
const errorMessage = `code of ${name} is empty or undefined`;
const invalidValues = [ '', undefined ];
invalidValues.forEach((invalidValue) => {
// act
const act = () => new ScriptCode(name, invalidValue, '');
// assert
expect(act).to.throw(errorMessage);
});
});
it('sets as expected', () => {
// arrange
const expected = 'expected-revert';
// act
const sut = createSut(expected);
// assert
expect(sut.execute).to.equal(expected);
});
});
describe('revert', () => {
it('cannot construct with duplicate lines', () => {
// arrange
const code = 'duplicate\nduplicate\ntest\nduplicate';
// act
const act = () => createSut('REM', code);
// assert
expect(act).to.throw();
});
it('cannot construct with empty lines', () => {
// arrange
const code = 'line1\n\n\nline2';
// act
const act = () => createSut('REM', code);
// assert
expect(act).to.throw();
});
it('cannot construct with when same as code', () => {
// arrange
const code = 'REM';
// act
const act = () => createSut(code, code);
// assert
expect(act).to.throw();
});
it('sets as expected', () => {
// arrange
const expected = 'expected-revert';
// act
const sut = createSut('abc', expected);
// assert
expect(sut.revert).to.equal(expected);
});
});
});
function createSut(code: string, revert = ''): ScriptCode {
return new ScriptCode('test-code', code, revert);
}

View File

@@ -3,11 +3,11 @@ import { expect } from 'chai';
import { getScriptNodeId, getScriptId, getCategoryNodeId, getCategoryId } from '@/presentation/Scripts/ScriptsTree/ScriptNodeParser';
import { CategoryStub } from '../../../stubs/CategoryStub';
import { ScriptStub } from '../../../stubs/ScriptStub';
import { parseSingleCategory, parseAllCategories } from '../../../../../src/presentation/Scripts/ScriptsTree/ScriptNodeParser';
import { parseSingleCategory, parseAllCategories } from '@/presentation/Scripts/ScriptsTree/ScriptNodeParser';
import { ApplicationStub } from '../../../stubs/ApplicationStub';
import { INode, NodeType } from '../../../../../src/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode';
import { IScript } from '../../../../../src/domain/IScript';
import { ICategory } from '../../../../../src/domain/ICategory';
import { INode, NodeType } from '@/presentation/Scripts/ScriptsTree/SelectableTree/Node/INode';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
describe('ScriptNodeParser', () => {
it('can convert script id and back', () => {
@@ -80,7 +80,7 @@ describe('ScriptNodeParser', () => {
function isReversible(category: ICategory): boolean {
if (category.scripts) {
return category.scripts.every((s) => s.revertCode);
return category.scripts.every((s) => s.canRevert());
}
return category.subCategories.every((c) => isReversible(c));
}
@@ -100,8 +100,8 @@ function expectSameCategory(node: INode, category: ICategory): void {
}
function getErrorMessage(field: string) {
return `Unexpected node field: ${field}.\n` +
`\nActual node:\n${JSON.stringify(node, null, 2)}` +
`\nExpected category:\n${JSON.stringify(category, null, 2)}`;
`\nActual node:\n${print(node)}` +
`\nExpected category:\n${print(category)}`;
}
}
@@ -110,11 +110,15 @@ function expectSameScript(node: INode, script: IScript): void {
expect(node.id).to.equal(getScriptNodeId(script), getErrorMessage('id'));
expect(node.documentationUrls).to.equal(script.documentationUrls, getErrorMessage('documentationUrls'));
expect(node.text).to.equal(script.name, getErrorMessage('name'));
expect(node.isReversible).to.equal(!!script.revertCode, getErrorMessage('revertCode'));
expect(node.isReversible).to.equal(script.canRevert(), getErrorMessage('canRevert'));
expect(node.children).to.equal(undefined);
function getErrorMessage(field: string) {
return `Unexpected node field: ${field}.` +
`\nActual node:\n${JSON.stringify(node, null, 2)}\n` +
`\nExpected script:\n${JSON.stringify(script, null, 2)}`;
`\nActual node:\n${print(node)}\n` +
`\nExpected script:\n${print(script)}`;
}
}
function print(object: any) {
return JSON.stringify(object, null, 2);
}

View File

@@ -4,7 +4,7 @@ import { ScriptReverter } from '@/presentation/Scripts/ScriptsTree/SelectableTre
import { SelectedScriptStub } from '../../../../../../stubs/SelectedScriptStub';
import { getScriptNodeId } from '@/presentation/Scripts/ScriptsTree/ScriptNodeParser';
import { ScriptStub } from '../../../../../../stubs/ScriptStub';
import { UserSelection } from '../../../../../../../../src/application/State/Selection/UserSelection';
import { UserSelection } from '@/application/State/Selection/UserSelection';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { ApplicationStub } from '../../../../../../stubs/ApplicationStub';
import { CategoryStub } from '../../../../../../stubs/CategoryStub';

View File

@@ -0,0 +1,18 @@
import { IScriptCompiler } from '@/application/Parser/Compiler/IScriptCompiler';
import { IScriptCode } from '@/domain/IScriptCode';
import { YamlScript } from 'js-yaml-loader!./application.yaml';
export class ScriptCompilerStub implements IScriptCompiler {
public compilables = new Map<YamlScript, IScriptCode>();
public canCompile(script: YamlScript): boolean {
return this.compilables.has(script);
}
public compile(script: YamlScript): IScriptCode {
return this.compilables.get(script);
}
public withCompileAbility(script: YamlScript, result?: IScriptCode): ScriptCompilerStub {
this.compilables.set(script, result ||
{ execute: `compiled code of ${script.name}`, revert: `compiled revert code of ${script.name}` });
return this;
}
}

View File

@@ -4,8 +4,10 @@ import { RecommendationLevel } from '@/domain/RecommendationLevel';
export class ScriptStub extends BaseEntity<string> implements IScript {
public name = `name${this.id}`;
public code = `REM code${this.id}`;
public revertCode = `REM revertCode${this.id}`;
public code = {
execute: `REM execute-code (${this.id})`,
revert: `REM revert-code (${this.id})`,
};
public readonly documentationUrls = new Array<string>();
public level = RecommendationLevel.Standard;
@@ -14,7 +16,7 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
}
public canRevert(): boolean {
return Boolean(this.revertCode);
return Boolean(this.code.revert);
}
public withLevel(value: RecommendationLevel): ScriptStub {
@@ -23,7 +25,7 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
}
public withCode(value: string): ScriptStub {
this.code = value;
this.code.execute = value;
return this;
}
@@ -33,7 +35,7 @@ export class ScriptStub extends BaseEntity<string> implements IScript {
}
public withRevertCode(revertCode: string): ScriptStub {
this.revertCode = revertCode;
this.code.revert = revertCode;
return this;
}
}

View File

@@ -0,0 +1,61 @@
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import { ScriptFunctionCall, YamlScript } from 'js-yaml-loader!./application.yaml';
export class YamlScriptStub implements YamlScript {
public static createWithCode(): YamlScriptStub {
return new YamlScriptStub()
.withCode('stub-code')
.withRevertCode('stub-revert-code');
}
public static createWithCall(call?: ScriptFunctionCall): YamlScriptStub {
let instance = new YamlScriptStub();
if (call) {
instance = instance.withCall(call);
} else {
instance = instance.withMockCall();
}
return instance;
}
public static createWithoutCallOrCodes(): YamlScriptStub {
return new YamlScriptStub();
}
public name = 'valid-name';
public code = undefined;
public revertCode = undefined;
public call = undefined;
public recommend = RecommendationLevel[RecommendationLevel.Standard].toLowerCase();
public docs = ['hello.com'];
private constructor() { }
public withName(name: string): YamlScriptStub {
this.name = name;
return this;
}
public withDocs(docs: string[]): YamlScriptStub {
this.docs = docs;
return this;
}
public withCode(code: string): YamlScriptStub {
this.code = code;
return this;
}
public withRevertCode(revertCode: string): YamlScriptStub {
this.revertCode = revertCode;
return this;
}
public withMockCall(): YamlScriptStub {
this.call = { function: 'func', parameters: [] };
return this;
}
public withCall(call: ScriptFunctionCall): YamlScriptStub {
this.call = call;
return this;
}
}