Refactor to unify scripts/categories as Executable

This commit consolidates scripts and categories under a unified
'Executable' concept. This simplifies the architecture and improves code
readability.

- Introduce subfolders within `src/domain` to segregate domain elements.
- Update class and interface names by removing the 'I' prefix in
  alignment with new coding standards.
- Replace 'Node' with 'Executable' to clarify usage; reserve 'Node'
  exclusively for the UI's tree component.
This commit is contained in:
undergroundwires
2024-06-12 12:36:40 +02:00
parent 8becc7dbc4
commit c138f74460
230 changed files with 1120 additions and 1039 deletions

View File

@@ -0,0 +1,33 @@
import { describe } from 'vitest';
import { EscapeDoubleQuotes } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes';
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { runPipeTests } from './PipeTestRunner';
describe('EscapeDoubleQuotes', () => {
// arrange
const sut = new EscapeDoubleQuotes();
// act
runPipeTests(sut, [
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
.map((testCase) => ({
name: 'returns as it is when if input is missing',
input: testCase.absentValue,
expectedOutput: testCase.absentValue,
})),
{
name: 'using "',
input: 'hello "world"',
expectedOutput: 'hello "^""world"^""',
},
{
name: 'not using any double quotes',
input: 'hello world',
expectedOutput: 'hello world',
},
{
name: 'consecutive double quotes',
input: '""hello world""',
expectedOutput: '"^"""^""hello world"^"""^""',
},
]);
});

View File

@@ -0,0 +1,465 @@
import { describe } from 'vitest';
import { InlinePowerShell } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeDefinitions/InlinePowerShell';
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { type IPipeTestCase, runPipeTests } from './PipeTestRunner';
describe('InlinePowerShell', () => {
// arrange
const sut = new InlinePowerShell();
// act
runPipeTests(sut, [
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
.map((testCase) => ({
name: 'returns as it is when if input is missing',
input: testCase.absentValue,
expectedOutput: '',
})),
...prefixTests('newline', getNewLineCases()),
...prefixTests('comment', getCommentCases()),
...prefixTests('here-string', hereStringCases()),
...prefixTests('backtick', backTickCases()),
]);
});
function hereStringCases(): IPipeTestCase[] {
const expectLinesInDoubleQuotes = (...lines: string[]) => lines.join('`r`n');
const expectLinesInSingleQuotes = (...lines: string[]) => lines.join('\'+"`r`n"+\'');
return [
{
name: 'adds newlines for double quotes',
input: getWindowsLines(
'@"',
'Lorem',
'ipsum',
'dolor sit amet',
'"@',
),
expectedOutput: expectLinesInDoubleQuotes(
'"Lorem',
'ipsum',
'dolor sit amet"',
),
},
{
name: 'adds newlines for single quotes',
input: getWindowsLines(
'@\'',
'Lorem',
'ipsum',
'dolor sit amet',
'\'@',
),
expectedOutput: expectLinesInSingleQuotes(
'\'Lorem',
'ipsum',
'dolor sit amet\'',
),
},
{
name: 'does not match with character after here string header',
input: getWindowsLines(
'@" invalid syntax',
'I will not be processed as here-string',
'"@',
),
expectedOutput: getSingleLinedOutput(
'@" invalid syntax',
'I will not be processed as here-string',
'"@',
),
},
{
name: 'does not match if there\'s character before here-string terminator',
input: getWindowsLines(
'@\'',
'do not match here',
' \'@',
'character \'@',
),
expectedOutput: getSingleLinedOutput(
'@\'',
'do not match here',
' \'@',
'character \'@',
),
},
{
name: 'does not match with different here-string header/terminator',
input: getWindowsLines(
'@\'',
'lorem',
'"@',
),
expectedOutput: getSingleLinedOutput(
'@\'',
'lorem',
'"@',
),
},
{
name: 'matches with inner single quoted here-string',
input: getWindowsLines(
'$hasInnerDoubleQuotedTerminator = @"',
'inner text',
'@\'',
'inner terminator text',
'\'@',
'"@',
),
expectedOutput: expectLinesInDoubleQuotes(
'$hasInnerDoubleQuotedTerminator = "inner text',
'@\'',
'inner terminator text',
'\'@"',
),
},
{
name: 'matches with inner double quoted string',
input: getWindowsLines(
'$hasInnerSingleQuotedTerminator = @\'',
'inner text',
'@"',
'inner terminator text',
'"@',
'\'@',
),
expectedOutput: expectLinesInSingleQuotes(
'$hasInnerSingleQuotedTerminator = \'inner text',
'@"',
'inner terminator text',
'"@\'',
),
},
{
name: 'matches if there\'s character after here-string terminator',
input: getWindowsLines(
'@\'',
'lorem',
'\'@ after',
),
expectedOutput: expectLinesInSingleQuotes(
'\'lorem\' after',
),
},
{
name: 'escapes double quotes inside double quotes',
input: getWindowsLines(
'@"',
'For help, type "get-help"',
'"@',
),
expectedOutput: '"For help, type `"get-help`""',
},
{
name: 'escapes single quotes inside single quotes',
input: getWindowsLines(
'@\'',
'For help, type \'get-help\'',
'\'@',
),
expectedOutput: '\'For help, type \'\'get-help\'\'\'',
},
{
name: 'converts when here-string header is not at line start',
input: getWindowsLines(
'$page = [XML] @"',
'multi-lined',
'and "quoted"',
'"@',
),
expectedOutput: expectLinesInDoubleQuotes(
'$page = [XML] "multi-lined',
'and `"quoted`""',
),
},
{
name: 'trims after here-string header',
input: getWindowsLines(
'@" \t',
'text with whitespaces at here-string start',
'"@',
),
expectedOutput: '"text with whitespaces at here-string start"',
},
{
name: 'preserves whitespaces in lines',
input: getWindowsLines(
'@\'',
'\ttext with tabs around\t\t',
' text with whitespaces around ',
'\'@',
),
expectedOutput: expectLinesInSingleQuotes(
'\'\ttext with tabs around\t\t',
' text with whitespaces around \'',
),
},
];
}
function backTickCases(): IPipeTestCase[] {
return [
{
name: 'wraps newlines with trailing backtick',
input: getWindowsLines(
'Get-Service * `',
'| Format-Table -AutoSize',
),
expectedOutput: 'Get-Service * | Format-Table -AutoSize',
},
{
name: 'wraps newlines with trailing backtick and different line endings',
input: 'Get-Service `\n'
+ '* `\r'
+ '| Sort-Object StartType `\r\n'
+ '| Format-Table -AutoSize',
expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize',
},
{
name: 'trims tabs and whitespaces on next lines when wrapping with trailing backtick',
input: getWindowsLines(
'Get-Service * `',
'\t| Sort-Object StartType `',
' | Format-Table -AutoSize',
),
expectedOutput: 'Get-Service * | Sort-Object StartType | Format-Table -AutoSize',
},
{
name: 'does not wrap without whitespace before backtick',
input: getWindowsLines(
'Get-Service *`',
'| Format-Table -AutoSize',
),
expectedOutput: getSingleLinedOutput(
'Get-Service *`',
'| Format-Table -AutoSize',
),
},
{
name: 'does not wrap with characters after',
input: getWindowsLines(
'line start ` after',
'should not be wrapped',
),
expectedOutput: getSingleLinedOutput(
'line start ` after',
'should not be wrapped',
),
},
];
}
function getCommentCases(): IPipeTestCase[] {
return [
{
name: 'converts hash comments in the line end',
input: getWindowsLines(
'$text = "Hello"\t# Comment after tab',
'$text+= #Comment without space after hash',
'Write-Host $text# Comment without space before hash',
),
expectedOutput: getSingleLinedOutput(
'$text = "Hello"\t<# Comment after tab #>',
'$text+= <# Comment without space after hash #>',
'Write-Host $text<# Comment without space before hash #>',
),
},
{
name: 'converts hash comment line',
input: getWindowsLines(
'# Comment in first line',
'Write-Host "Hello"',
'# Comment in the middle',
'Write-Host "World"',
'# Consecutive comments',
'# Last line comment without line ending in the end',
),
expectedOutput: getSingleLinedOutput(
'<# Comment in first line #>',
'Write-Host "Hello"',
'<# Comment in the middle #>',
'Write-Host "World"',
'<# Consecutive comments #>',
'<# Last line comment without line ending in the end #>',
),
},
{
name: 'can convert comment with inline comment parts inside',
input: getWindowsLines(
'$text+= #Comment with < inside',
'$text+= #Comment ending with >',
'$text+= #Comment with <# inline comment #>',
),
expectedOutput: getSingleLinedOutput(
'$text+= <# Comment with < inside #>',
'$text+= <# Comment ending with > #>',
'$text+= <# Comment with <# inline comment #> #>',
),
},
{
name: 'can convert comment with inline comment parts around', // Pretty uncommon
input: getWindowsLines(
'Write-Host "hi" # Comment ending line inline comment but not one #>',
'Write-Host "hi" #>Comment starting like inline comment end but not one',
// Following line does not compile as valid PowerShell due to missing #> for inline comment.
'Write-Host "hi" <#Comment starting like inline comment start but not one',
),
expectedOutput: getSingleLinedOutput(
'Write-Host "hi" <# Comment ending line inline comment but not one #> #>',
'Write-Host "hi" <# >Comment starting like inline comment end but not one #>',
'Write-Host "hi" <<# Comment starting like inline comment start but not one #>',
),
},
{
name: 'converts empty hash comment',
input: getWindowsLines(
'Write-Host "Comment without text" #',
'Write-Host "Non-empty line"',
),
expectedOutput: getSingleLinedOutput(
'Write-Host "Comment without text" <##>',
'Write-Host "Non-empty line"',
),
},
{
name: 'adds whitespaces around to match',
input: getWindowsLines(
'#Comment line with no whitespaces around',
'Write-Host "Hello"#Comment in the end with no whitespaces around',
),
expectedOutput: getSingleLinedOutput(
'<# Comment line with no whitespaces around #>',
'Write-Host "Hello"<# Comment in the end with no whitespaces around #>',
),
},
{
name: 'trims whitespaces around comment',
input: getWindowsLines(
'# Comment with whitespaces around ',
'#\tComment with tabs around\t\t',
'#\t Comment with tabs and whitespaces around \t \t',
),
expectedOutput: getSingleLinedOutput(
'<# Comment with whitespaces around #>',
'<# Comment with tabs around #>',
'<# Comment with tabs and whitespaces around #>',
),
},
{
name: 'does not convert block comments',
input: getWindowsLines(
'$text = "Hello"\t<# block comment #> + "World"',
'$text = "Hello"\t+<#comment#>"World"',
'<# Block comment in a line #>',
'Write-Host "Hello world <# Block comment in the end of line #>',
),
expectedOutput: getSingleLinedOutput(
'$text = "Hello"\t<# block comment #> + "World"',
'$text = "Hello"\t+<#comment#>"World"',
'<# Block comment in a line #>',
'Write-Host "Hello world <# Block comment in the end of line #>',
),
},
{
name: 'does not process if there are no multi lines',
input: 'Write-Host "expected" # as it is!',
expectedOutput: 'Write-Host "expected" # as it is!',
},
];
}
function getNewLineCases(): IPipeTestCase[] {
return [
{
name: 'no new line',
input: 'Write-Host \'Hello, World!\'',
expectedOutput: 'Write-Host \'Hello, World!\'',
},
{
name: '\\n new line',
input:
'$things = Get-ChildItem C:\\Windows\\'
+ '\nforeach ($thing in $things) {'
+ '\nWrite-Host $thing.Name -ForegroundColor Magenta'
+ '\n}',
expectedOutput: getSingleLinedOutput(
'$things = Get-ChildItem C:\\Windows\\',
'foreach ($thing in $things) {',
'Write-Host $thing.Name -ForegroundColor Magenta',
'}',
),
},
{
name: '\\n double empty lines are ignored',
input:
'$things = Get-ChildItem C:\\Windows\\'
+ '\n\nforeach ($thing in $things) {'
+ '\n\nWrite-Host $thing.Name -ForegroundColor Magenta'
+ '\n\n\n}',
expectedOutput: getSingleLinedOutput(
'$things = Get-ChildItem C:\\Windows\\',
'foreach ($thing in $things) {',
'Write-Host $thing.Name -ForegroundColor Magenta',
'}',
),
},
{
name: '\\r new line',
input:
'$things = Get-ChildItem C:\\Windows\\'
+ '\rforeach ($thing in $things) {'
+ '\rWrite-Host $thing.Name -ForegroundColor Magenta'
+ '\r}',
expectedOutput: getSingleLinedOutput(
'$things = Get-ChildItem C:\\Windows\\',
'foreach ($thing in $things) {',
'Write-Host $thing.Name -ForegroundColor Magenta',
'}',
),
},
{
name: '\\r and \\n newlines combined',
input:
'$things = Get-ChildItem C:\\Windows\\'
+ '\r\nforeach ($thing in $things) {'
+ '\n\rWrite-Host $thing.Name -ForegroundColor Magenta'
+ '\n\r}',
expectedOutput: getSingleLinedOutput(
'$things = Get-ChildItem C:\\Windows\\',
'foreach ($thing in $things) {',
'Write-Host $thing.Name -ForegroundColor Magenta',
'}',
),
},
{
name: 'trims whitespaces on lines',
input:
' $things = Get-ChildItem C:\\Windows\\ '
+ '\nforeach ($thing in $things) {'
+ '\n\tWrite-Host $thing.Name -ForegroundColor Magenta'
+ '\r \n}',
expectedOutput: getSingleLinedOutput(
'$things = Get-ChildItem C:\\Windows\\',
'foreach ($thing in $things) {',
'Write-Host $thing.Name -ForegroundColor Magenta',
'}',
),
},
];
}
function prefixTests(prefix: string, tests: IPipeTestCase[]): IPipeTestCase[] {
return tests.map((test) => ({
name: `[${prefix}] ${test.name}`,
input: test.input,
expectedOutput: test.expectedOutput,
}));
}
function getWindowsLines(...lines: string[]) {
return lines.join('\r\n');
}
function getSingleLinedOutput(...lines: string[]) {
return lines.map((line) => line.trim()).join('; ');
}

View File

@@ -0,0 +1,19 @@
import { it, expect } from 'vitest';
import type { IPipe } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipe';
export interface IPipeTestCase {
readonly name: string;
readonly input: string;
readonly expectedOutput: string;
}
export function runPipeTests(sut: IPipe, testCases: IPipeTestCase[]) {
for (const testCase of testCases) {
it(testCase.name, () => {
// act
const actual = sut.apply(testCase.input);
// assert
expect(actual).to.equal(testCase.expectedOutput);
});
}
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { PipeFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory';
import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub';
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
describe('PipeFactory', () => {
describe('ctor', () => {
it('throws when instances with same name is registered', () => {
// arrange
const duplicateName = 'duplicateName';
const expectedError = `Pipe name must be unique: "${duplicateName}"`;
const pipes = [
new PipeStub().withName(duplicateName),
new PipeStub().withName('uniqueName'),
new PipeStub().withName(duplicateName),
];
// act
const act = () => new PipeFactory(pipes);
// expect
expect(act).to.throw(expectedError);
});
describe('throws when name is invalid', () => {
// act
const act = (invalidName: string) => new PipeFactory([new PipeStub().withName(invalidName)]);
// assert
testPipeNameValidation(act);
});
});
describe('get', () => {
describe('throws when name is invalid', () => {
// arrange
const sut = new PipeFactory();
// act
const act = (invalidName: string) => sut.get(invalidName);
// assert
testPipeNameValidation(act);
});
it('gets registered instance when it exists', () => {
// arrange
const expected = new PipeStub().withName('expectedName');
const pipes = [expected, new PipeStub().withName('instanceToConfuse')];
const sut = new PipeFactory(pipes);
// act
const actual = sut.get(expected.name);
// expect
expect(actual).to.equal(expected);
});
it('throws when instance does not exist', () => {
// arrange
const missingName = 'missingName';
const expectedError = `Unknown pipe: "${missingName}"`;
const pipes = [];
const sut = new PipeFactory(pipes);
// act
const act = () => sut.get(missingName);
// expect
expect(act).to.throw(expectedError);
});
});
});
function testPipeNameValidation(testRunner: (invalidName: string) => void) {
const testCases = [
// Validate missing value
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
.map((testCase) => ({
name: `empty pipe name (${testCase.valueName})`,
value: testCase.absentValue,
expectedError: 'empty pipe name',
})),
// Validate camelCase
...[
'PascalCase',
'snake-case',
'includesNumb3rs',
'includes Whitespace',
'noSpec\'ial',
].map((nonCamelCaseValue) => ({
name: `non camel case value (${nonCamelCaseValue})`,
value: nonCamelCaseValue,
expectedError: `Pipe name should be camelCase: "${nonCamelCaseValue}"`,
})),
];
for (const testCase of testCases) {
it(testCase.name, () => {
// arrange
const invalidName = testCase.value;
const { expectedError } = testCase;
// act
const act = () => testRunner(invalidName);
// expect
expect(act).to.throw(expectedError);
});
}
}

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import { PipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipelineCompiler';
import { type IPipelineCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/IPipelineCompiler';
import type { IPipeFactory } from '@/application/Parser/Executable/Script/Compiler/Expressions/Pipes/PipeFactory';
import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub';
import { PipeFactoryStub } from '@tests/unit/shared/Stubs/PipeFactoryStub';
import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
describe('PipelineCompiler', () => {
describe('compile', () => {
describe('throws for invalid arguments', () => {
interface ITestCase {
readonly name: string;
readonly act: (test: PipelineTestRunner) => PipelineTestRunner;
readonly expectedError: string;
}
const testCases: ITestCase[] = [
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
.map((testCase) => ({
name: `"value" is ${testCase.valueName}`,
act: (test) => test.withValue(testCase.absentValue),
expectedError: 'missing value',
})),
...getAbsentStringTestCases({ excludeNull: true, excludeUndefined: true })
.map((testCase) => ({
name: `"pipeline" is ${testCase.valueName}`,
act: (test) => test.withPipeline(testCase.absentValue),
expectedError: 'missing pipeline',
})),
{
name: '"pipeline" does not start with pipe',
act: (test) => test.withPipeline('pipeline |'),
expectedError: 'pipeline does not start with pipe',
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
// act
const runner = new PipelineTestRunner();
testCase.act(runner);
const act = () => runner.compile();
// assert
expect(act).to.throw(testCase.expectedError);
});
}
});
describe('compiles pipeline as expected', () => {
const testCases = [
{
name: 'compiles single pipe as expected',
pipes: [
new PipeStub().withName('doublePrint').withApplier((value) => `${value}-${value}`),
],
pipeline: '| doublePrint',
value: 'value',
expected: 'value-value',
},
{
name: 'compiles multiple pipes as expected',
pipes: [
new PipeStub().withName('prependLetterA').withApplier((value) => `A-${value}`),
new PipeStub().withName('prependLetterB').withApplier((value) => `B-${value}`),
],
pipeline: '| prependLetterA | prependLetterB',
value: 'value',
expected: 'B-A-value',
},
{
name: 'compiles with relaxed whitespace placing',
pipes: [
new PipeStub().withName('appendNumberOne').withApplier((value) => `${value}1`),
new PipeStub().withName('appendNumberTwo').withApplier((value) => `${value}2`),
new PipeStub().withName('appendNumberThree').withApplier((value) => `${value}3`),
],
pipeline: ' | appendNumberOne|appendNumberTwo| appendNumberThree',
value: 'value',
expected: 'value123',
},
{
name: 'can reuse same pipe',
pipes: [
new PipeStub().withName('removeFirstChar').withApplier((value) => `${value.slice(1)}`),
],
pipeline: ' | removeFirstChar | removeFirstChar | removeFirstChar',
value: 'value',
expected: 'ue',
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
// arrange
const runner = new PipelineTestRunner()
.withValue(testCase.value)
.withPipeline(testCase.pipeline)
.withFactory(new PipeFactoryStub().withPipes(testCase.pipes));
// act
const actual = runner.compile();
// expect
expect(actual).to.equal(testCase.expected);
});
}
});
});
});
class PipelineTestRunner implements IPipelineCompiler {
private value = 'non-empty-value';
private pipeline = '| validPipeline';
private factory: IPipeFactory = new PipeFactoryStub();
public withValue(value: string) {
this.value = value;
return this;
}
public withPipeline(pipeline: string) {
this.pipeline = pipeline;
return this;
}
public withFactory(factory: IPipeFactory) {
this.factory = factory;
return this;
}
public compile(): string {
const sut = new PipelineCompiler(this.factory);
return sut.compile(this.value, this.pipeline);
}
}