From a875c072490f5126af492198c59267a341015e48 Mon Sep 17 00:00:00 2001 From: Martin Muzikar Date: Sat, 29 Feb 2020 14:15:39 +0100 Subject: [PATCH] Implemented side by side view, added logging feature Side by side Service implementation Implemented loggin feature --- .gitignore | 7 +- .../main/java/mmuzikar/handlers/Handlers.java | 3 +- .../java/mmuzikar/handlers/LogHandler.java | 85 ++++++++ .../interceptors/CucumberInterceptor.java | 3 +- .../interceptors/ExposeInterceptor.java | 2 +- .../src/test/java/mmuzikar/Stepdefs.java | 12 + ui/.gitignore | 25 --- ui/gherkin.monarch | 24 ++ ui/package.json | 23 +- ui/public/index.html | 8 + ui/src/App.css | 8 + ui/src/App.tsx | 27 ++- ui/src/components/Editor.tsx | 24 -- ui/src/components/History.tsx | 28 --- ui/src/components/InputEditor.tsx | 75 +++++++ ui/src/components/Log.tsx | 119 ++++++++++ ui/src/components/OutputEditor.tsx | 206 ++++++++++++++++++ ui/src/components/Step.tsx | 2 +- ui/src/components/StepList.tsx | 58 ++++- ui/src/components/TerminalInput.tsx | 15 +- ui/src/components/Toolbox.tsx | 12 + ui/src/editor/CommonFeatures.ts | 20 ++ ui/src/editor/EditorActions.ts | 144 ++++++++++++ ui/src/editor/FeatureHighlight.ts | 78 +++++++ ui/src/editor/OutputEditorActions.ts | 39 ++++ ui/src/interop/Cucumber.ts | 49 +++++ ui/src/interop/Services.ts | 59 +++++ ui/src/interop/config.ts | 1 - ui/src/interop/cucumberTypes.ts | 97 ++++++++- ui/src/interop/historyManager.ts | 47 ---- ui/src/interop/services/CommentService.ts | 19 ++ ui/src/interop/services/CucumberService.ts | 71 ++++++ ui/src/interop/services/Service.ts | 50 +++++ ui/src/interop/services/UnknownOpService.ts | 15 ++ ui/src/interop/services/VariableService.ts | 38 ++++ ui/src/interop/stepManager.ts | 64 ++---- ui/src/styles/editors.css | 14 ++ ui/src/styles/log.css | 35 +++ ui/src/styles/outputEditor.css | 10 + ui/src/styles/style.module.css | 2 +- ui/tsconfig.json | 7 +- ui/webpack.config.js | 75 +++++++ 42 files changed, 1482 insertions(+), 218 deletions(-) create mode 100644 backendprovider/src/main/java/mmuzikar/handlers/LogHandler.java delete mode 100644 ui/.gitignore create mode 100644 ui/gherkin.monarch delete mode 100644 ui/src/components/Editor.tsx delete mode 100644 ui/src/components/History.tsx create mode 100644 ui/src/components/InputEditor.tsx create mode 100644 ui/src/components/Log.tsx create mode 100644 ui/src/components/OutputEditor.tsx create mode 100644 ui/src/components/Toolbox.tsx create mode 100644 ui/src/editor/CommonFeatures.ts create mode 100644 ui/src/editor/EditorActions.ts create mode 100644 ui/src/editor/FeatureHighlight.ts create mode 100644 ui/src/editor/OutputEditorActions.ts create mode 100644 ui/src/interop/Cucumber.ts create mode 100644 ui/src/interop/Services.ts delete mode 100644 ui/src/interop/historyManager.ts create mode 100644 ui/src/interop/services/CommentService.ts create mode 100644 ui/src/interop/services/CucumberService.ts create mode 100644 ui/src/interop/services/Service.ts create mode 100644 ui/src/interop/services/UnknownOpService.ts create mode 100644 ui/src/interop/services/VariableService.ts create mode 100644 ui/src/styles/editors.css create mode 100644 ui/src/styles/log.css create mode 100644 ui/src/styles/outputEditor.css create mode 100644 ui/webpack.config.js diff --git a/.gitignore b/.gitignore index 4a7c8bd..0873e57 100644 --- a/.gitignore +++ b/.gitignore @@ -134,4 +134,9 @@ dist # TernJS port file .tern-port ui/old/ -.vscode/ \ No newline at end of file +.vscode/ + +ui/lib/* +backendprovider/.factorypath +testsuite/.factorypath +*.lock \ No newline at end of file diff --git a/backendprovider/src/main/java/mmuzikar/handlers/Handlers.java b/backendprovider/src/main/java/mmuzikar/handlers/Handlers.java index 3818c14..0d6b384 100644 --- a/backendprovider/src/main/java/mmuzikar/handlers/Handlers.java +++ b/backendprovider/src/main/java/mmuzikar/handlers/Handlers.java @@ -7,7 +7,8 @@ public enum Handlers { RUN_STEP(new RunStepHandler()), LIST_STEPS(new ListStepsHandler()), ADD_STEP(new AddStepHandler()), - SUGGEST(new SuggestionHandler()); + SUGGEST(new SuggestionHandler()), + LOG(new LogHandler()); @Getter Handler handler; diff --git a/backendprovider/src/main/java/mmuzikar/handlers/LogHandler.java b/backendprovider/src/main/java/mmuzikar/handlers/LogHandler.java new file mode 100644 index 0000000..c8adfdd --- /dev/null +++ b/backendprovider/src/main/java/mmuzikar/handlers/LogHandler.java @@ -0,0 +1,85 @@ +package mmuzikar.handlers; + +import com.sun.net.httpserver.HttpExchange; +import gherkin.deps.com.google.gson.Gson; +import lombok.AllArgsConstructor; +import lombok.extern.java.Log; +import org.apache.commons.io.output.TeeOutputStream; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +@Log +public class LogHandler implements Handler { + + private ByteArrayOutputStream outputCopyStream; + private ByteArrayOutputStream outputErrorStream; + private List records; + + private void setupStdOutput(){ + outputCopyStream = new ByteArrayOutputStream(1000); + outputErrorStream = new ByteArrayOutputStream(1000); + OutputStream outputTee = new TeeOutputStream(System.out, outputCopyStream); + OutputStream errorTee = new TeeOutputStream(System.err, outputErrorStream); + PrintStream outputPrint = new PrintStream(outputTee); + PrintStream errorPrint = new PrintStream(errorTee); + System.setOut(outputPrint); + System.setErr(errorPrint); + } + + private void setupLoggers(){ + Logger global = Logger.getGlobal(); + while (global.getParent() != null){ + global = global.getParent(); + } + global.addHandler(new java.util.logging.Handler() { + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + + } + + @Override + public void close() throws SecurityException { + + } + }); + } + + public LogHandler() { + records = new ArrayList<>(20); + setupLoggers(); + setupStdOutput(); + } + + @AllArgsConstructor + private class LogPojo { + public Object json; + public String stdout; + public String stderr; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + LogPojo log = new LogPojo(records, outputCopyStream.toString(), outputErrorStream.toString()); + Handler.sendResponse(exchange, new Gson().toJson(log)); + records.clear(); + outputCopyStream.reset(); + outputErrorStream.reset(); + } catch (Exception e) { + e.printStackTrace(); + Handler.sendResponse(exchange, e.getMessage(), 500); + } + } +} diff --git a/backendprovider/src/main/java/mmuzikar/interceptors/CucumberInterceptor.java b/backendprovider/src/main/java/mmuzikar/interceptors/CucumberInterceptor.java index 9b2f370..86e9c89 100644 --- a/backendprovider/src/main/java/mmuzikar/interceptors/CucumberInterceptor.java +++ b/backendprovider/src/main/java/mmuzikar/interceptors/CucumberInterceptor.java @@ -8,6 +8,7 @@ import lombok.extern.java.Log; import net.bytebuddy.implementation.bind.annotation.RuntimeType; import net.bytebuddy.implementation.bind.annotation.This; +import org.apache.commons.io.output.TeeOutputStream; import org.junit.runner.notification.RunNotifier; import org.junit.runners.model.InitializationError; import org.junit.runners.model.Statement; @@ -15,7 +16,7 @@ import mmuzikar.handlers.Handlers; import mmuzikar.processors.StepDefProcessor; -import java.io.IOException; +import java.io.*; import java.lang.reflect.Field; import java.net.InetSocketAddress; import java.util.List; diff --git a/backendprovider/src/main/java/mmuzikar/interceptors/ExposeInterceptor.java b/backendprovider/src/main/java/mmuzikar/interceptors/ExposeInterceptor.java index cbdc82a..0145099 100644 --- a/backendprovider/src/main/java/mmuzikar/interceptors/ExposeInterceptor.java +++ b/backendprovider/src/main/java/mmuzikar/interceptors/ExposeInterceptor.java @@ -22,7 +22,7 @@ public boolean process(Set annotations, RoundEnvironment annotatedElements.forEach(o ->{ switch (o.getKind()){ case METHOD: - ExecutableType exec = ((ExecutableType) o); +// ExecutableType exec = ((ExecutableType) o); break; case CLASS: diff --git a/testsuite/src/test/java/mmuzikar/Stepdefs.java b/testsuite/src/test/java/mmuzikar/Stepdefs.java index 1314ea9..5b9a782 100644 --- a/testsuite/src/test/java/mmuzikar/Stepdefs.java +++ b/testsuite/src/test/java/mmuzikar/Stepdefs.java @@ -1,6 +1,8 @@ package mmuzikar; import com.codeborne.selenide.Selenide; + +import cucumber.api.DataTable; import cucumber.api.java.en.When; import lombok.extern.java.Log; import org.junit.Assert; @@ -11,6 +13,8 @@ import static com.codeborne.selenide.Selenide.$; +import java.util.Map; + @Log public class Stepdefs { @@ -36,4 +40,12 @@ public void verifyInputValue(@Suggestion(InputSuggestion.class) String selector, public void clickOnLink(@Suggestion(LinkTextSuggestion.class) String linkText){ $(By.linkText(linkText)).click(); } + + @When("fill form named \"([^\"]*)\"") + public void tableUsage(String str, DataTable table){ + Map formData = table.asMap(String.class, String.class); + formData.entrySet().forEach(entry -> { + $(By.id(str)).$(By.name(entry.getKey())).setValue(entry.getValue()); + }); + } } \ No newline at end of file diff --git a/ui/.gitignore b/ui/.gitignore deleted file mode 100644 index ba63f07..0000000 --- a/ui/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# production -/build - -# misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -ui/old/** diff --git a/ui/gherkin.monarch b/ui/gherkin.monarch new file mode 100644 index 0000000..4f28c13 --- /dev/null +++ b/ui/gherkin.monarch @@ -0,0 +1,24 @@ +// Create your own language definition here +// You can safely look at other samples without losing modifications. +// Modifications are not saved on browser refresh/close though -- copy often! +return { + // Set defaultToken to invalid to see what you do not tokenize yet + // defaultToken: 'invalid', + + keywords: [ + 'Feature', 'Scenario', 'Background', 'Then', 'When', 'And', 'Given', 'But' + ], + + // The main tokenizer for our languages + tokenizer: { + root: [ + [/#.*$/, 'comment'], + [/@[\w\-]*/, 'annotation'], + [/[A-Z][a-z]*/, {cases: { + '@keywords': 'keyword' + }}], + [/"[^\"]*"/, 'string'], + [/\|/, 'delimiter'] + ], + }, +}; diff --git a/ui/package.json b/ui/package.json index eade74b..6aa6aea 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,23 +9,20 @@ "@types/flux": "^3.1.9", "@types/jest": "^24.0.0", "@types/node": "^12.0.0", - "@types/react": "^16.9.0", - "@types/react-dom": "^16.9.0", "@types/react-grid-layout": "^0.17.0", "babel-plugin-react-css-modules": "^5.2.6", "flux": "^3.1.3", "fuse.js": "^3.4.6", "monaco-editor": "^0.19.3", - "react": "^16.12.0", - "react-dom": "^16.12.0", + "react": "^16.13.0", + "react-dom": "^16.13.0", "react-grid-layout": "^0.17.1", "react-monaco-editor": "^0.33.0", "react-resize-panel": "^0.3.5", - "react-scripts": "3.3.1", - "typescript": "~3.7.2" + "react-scripts": "3.4.0" }, "scripts": { - "start": "react-scripts start", + "start": "npx webpack-dev-server --open --mode development", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" @@ -44,5 +41,17 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "@types/react": "^16.9.23", + "@types/react-dom": "^16.9.5", + "html-loader": "^0.5.5", + "html-webpack-plugin": "^3.2.0", + "monaco-editor-webpack-plugin": "^1.9.0", + "source-map-loader": "^0.2.4", + "ts-loader": "^6.2.1", + "typescript": "^3.7.5", + "webpack-cli": "^3.3.11", + "webpack-serve": "^3.2.0" } } diff --git a/ui/public/index.html b/ui/public/index.html index aa069f2..b5eb2fa 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -24,6 +24,14 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> + React App diff --git a/ui/src/App.css b/ui/src/App.css index 64bf806..4738942 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -27,3 +27,11 @@ content: ">"; color: blue; } + + +.marginGlyphStatus-success{ + background: '00ff00' +} +.marginGlyphStatus-failure{ + background: 'ff0000' +} \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0b0ca2d..679a3e4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,19 +2,20 @@ import React, { Component } from 'react'; import './App.css'; import '../node_modules/react-grid-layout/css/styles.css'; import '../node_modules/react-resizable/css/styles.css'; -import { } from "react-grid-layout"; import RGL, { WidthProvider, Layout } from "react-grid-layout"; import { StepList } from './components/StepList'; -import { TerminalInput } from './components/TerminalInput'; -import { History } from './components/History'; -import { Editor } from './components/Editor'; +import { InputEditor } from './components/InputEditor'; +import { OutputEditor } from './components/OutputEditor'; +import { Logger } from './components/Log'; +import { Toolbox } from './components/Toolbox'; const ReactGridLayout = WidthProvider(RGL); const layout : Layout[] = [ - {i: "history", x: 0, y: 0, w: 8, h: 8}, + {i: "input-editor", x: 0, y: 0, w: 4, h: 6}, + {i: "output-editor", x: 4, y: 0, w: 4, h: 6}, {i: "step-list", x: 8, y:0, w: 2, h: 5}, - {i: "terminal", x: 0, y: 8, w: 8, h: 1}, - {i: "toolbox", x:8, y:5, w: 2, h:4} + {i: "toolbox", x:8, y:5, w: 2, h:4}, + {i: "log", x:0, y: 6, w: 8, h: 3} ] type State = { @@ -29,14 +30,16 @@ class App extends Component<{}, State> { } render(){ + const rowHeight = (window.innerHeight-5)/10; + const colWidth = (window.innerWidth)/10; return (
- - -
+ +
+
-
TODO: add toolbox (favorites {"&"} macros)
+
+
); diff --git a/ui/src/components/Editor.tsx b/ui/src/components/Editor.tsx deleted file mode 100644 index 1ddd083..0000000 --- a/ui/src/components/Editor.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { Component } from "react"; -import MonacoEditor from "react-monaco-editor"; -import { HistoryManager } from "../interop/historyManager"; - -type State = { - code: string -} - -export class Editor extends Component<{}, State> { - - state = { - code: "" - } - - componentDidMount(){ - this.setState({ - code: HistoryManager.get().getScenario() - }); - } - - render(){ - return - } -} \ No newline at end of file diff --git a/ui/src/components/History.tsx b/ui/src/components/History.tsx deleted file mode 100644 index 4b88680..0000000 --- a/ui/src/components/History.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { Component } from "react"; -import { StepManager } from "../interop/stepManager"; -import { ResultType } from "../interop/feedback"; -import { HistoryManager, HistoryEntry } from "../interop/historyManager"; - -export class History extends Component<{}, {history: HistoryEntry[]}> { - - componentDidMount(){ - HistoryManager.get().historyDispatcher.register((_) => this.forceUpdate()); - } - - render(){ - return
    - {HistoryManager.get().history.map((hist, id) => )} -
; - } - -} - -class CHistoryEntry extends Component<{entry: HistoryEntry}> { - - render(){ - return
  • - {this.props.entry.step} -
  • - } - -} \ No newline at end of file diff --git a/ui/src/components/InputEditor.tsx b/ui/src/components/InputEditor.tsx new file mode 100644 index 0000000..f90dde4 --- /dev/null +++ b/ui/src/components/InputEditor.tsx @@ -0,0 +1,75 @@ +import React, { Component } from "react"; +import MonacoEditor from "react-monaco-editor"; +import * as monaco from "monaco-editor"; +import { registerCommonExtensions, INPUT_ID, registerInputEditorExtensions } from "../editor/CommonFeatures"; +import { StepManager } from "../interop/stepManager"; +import { Cucumber } from "../interop/Cucumber"; +import { Step } from "../interop/cucumberTypes"; +import { registerEditorActions } from "../editor/EditorActions"; +import "../styles/editors.css"; + +type Props = { + rowHeight: number, + colWidth: number +} + +type State = { + editor: monaco.editor.IStandaloneCodeEditor +} + +export class InputEditor extends Component { + + constructor(props:Props){ + super(props); + this.mountEditor = this.mountEditor.bind(this); + this.editorChanged = this.editorChanged.bind(this); + } + + options : monaco.editor.IEditorConstructionOptions = { + minimap: { + enabled: false + }, + dimension: { + width: this.props.colWidth * 4 - 30, + height: this.props.rowHeight * 6, + } + } + + mountEditor(e: monaco.editor.IStandaloneCodeEditor){ + this.setState({ + editor: e + }); + e.setModel(monaco.editor.createModel(``, INPUT_ID)); + registerCommonExtensions(); + registerInputEditorExtensions(e); + } + + decorations?: string[]; + + async editorChanged(val:string, event:monaco.editor.IModelContentChangedEvent){ + let lines : number[] = []; + val.split('\n').forEach((str, i) => { + if (Cucumber.isStep(str)){ + const step = Cucumber.findRightStep(str); + if (!(step instanceof Step)){ + lines.push(i + 1); + } + } + }) + this.decorations = this.state.editor.deltaDecorations(this.decorations || [], lines.map((i) => ({ + range: new monaco.Range(i, 1, i, 1), + options: { + isWholeLine: true, + inlineClassName: 'squiggly-error' + } + } as monaco.editor.IModelDeltaDecoration))); + } + + + render(){ + return
    +

    Input console

    + +
    + } +} \ No newline at end of file diff --git a/ui/src/components/Log.tsx b/ui/src/components/Log.tsx new file mode 100644 index 0000000..1d01389 --- /dev/null +++ b/ui/src/components/Log.tsx @@ -0,0 +1,119 @@ +import React, { Component, PureComponent } from "react"; +import { AppConfig } from "../interop/config"; +import "../styles/log.css"; + +interface Level { + name: string; + value?: number; + resourceBundleName?: string; +} + +interface Record { + level: Level; + sequenceNumber?: number; + sourceClassName?: string; + sourceMethodName?: string; + message: string; + threadID?: number; + millis?: any; + loggerName?: string; +} + +interface LogData { + json: Record[], + stdout: string, + stderr: string +} + +type State = { + records: Record[], + follow: boolean +} + +export class Logger extends Component<{}, State> { + + timerId: number | undefined; + capacity = 20; + loggerRef = React.createRef(); + + constructor(props: any) { + super(props); + this.refresh = this.refresh.bind(this); + this.toggleFollowing = this.toggleFollowing.bind(this); + } + + componentDidMount() { + this.timerId = window.setInterval(this.refresh, 500); + } + + state = { + records: [] as Record[], + follow: true + } + + refresh() { + fetch(`${AppConfig.getServerUrl()}/log`).then(resp => resp.json()).then((data: LogData) => { + if (data.json.length > 0 || data.stderr.length > 0 || data.stdout.length > 0) { + this.setState(old => { + let r: Record[] = []; + if (old.records.length > 0) { + r.push(...old.records); + } + if (data.json.length > 0) { + r.push(...data.json); + } + if (data.stdout.length > 0) { + r.push({ level: { name: "STDOUT" }, message: data.stdout }); + } + if (data.stderr.length > 0) { + r.push({ level: { name: "STDERR" }, message: data.stderr }); + } + r = r.slice(-this.capacity) + return { + records: r + } + }, () => { + if (this.state.follow && this.loggerRef.current){ + const ref = this.loggerRef.current; + ref.scrollTop = ref.scrollHeight; + } + }); + + } + }); + } + + componentWillUnmount() { + if (this.timerId) { + clearInterval(this.timerId); + } + } + + toggleFollowing() { + this.setState(old => ({ + follow: !old.follow + })); + } + + render() { + const logs = this.state.records.map((rec, i) => ) as JSX.Element[]; + return ; + } + +} + +class Log extends Component<{ record: Record }, {}> { + + render() { + const r = this.props.record; + return
    +
    + {r.message.split("\n").map(str =>

    {str}

    )} +
    +
    + } + +} \ No newline at end of file diff --git a/ui/src/components/OutputEditor.tsx b/ui/src/components/OutputEditor.tsx new file mode 100644 index 0000000..78ea94e --- /dev/null +++ b/ui/src/components/OutputEditor.tsx @@ -0,0 +1,206 @@ +import React, { Component } from "react"; +import MonacoEditor from "react-monaco-editor"; +import * as monaco from "monaco-editor"; +import "../styles/outputEditor.css" +import { Services } from "../interop/Services"; +import { CucumberService, CucumberServiceResult } from "../interop/services/CucumberService"; +import { ServiceResult, Service } from "../interop/services/Service"; +import { ResultType } from "../interop/feedback"; +import { OUTPUT_ID, registerOutputEditorExtensions } from "../editor/CommonFeatures"; +import { CommentService } from "../interop/services/CommentService"; +import { VariableService } from "../interop/services/VariableService"; + +const getVarService = () => Services.get().services.find(svc => svc instanceof VariableService) as VariableService; +const getVar = (key:string) => getVarService().getValue(key) || key; +type Props = { + rowHeight: number, + colWidth: number +} +enum Type { + STEP, + COMMENT, + GHERKINTEXT +} + +type Line = {text:string, decorations?:monaco.editor.IModelDecorationOptions}; + +interface EditorLine { + getLine():Line; +} + +class Step implements EditorLine { + type : Type = Type.STEP; + constructor(public value:string, public id:number, public status:ResultType){} + getLine():Line{ + return { + text: this.value, + decorations: { + isWholeLine: true, + glyphMarginClassName: `marginGlyphStatus-${this.status}` + } + } + } +} +class Comment implements EditorLine { + type : Type = Type.COMMENT; + constructor(public value:string){} + getLine():Line{ + return { + text: this.value + } + } +} +class GherkinText implements EditorLine { + type : Type = Type.GHERKINTEXT; + constructor(public value:string, public keyword:string){} + getLine():Line{ + return { + text: `${this.keyword}: ${getVar(this.value.substring(1, this.value.length))}` + } + } +} +type Entry = Step | Comment | GherkinText; + +type State = { + editor: monaco.editor.IStandaloneCodeEditor, + steps: Entry[] +} + +export class OutputEditor extends Component { + + config : monaco.editor.IEditorConstructionOptions = { + glyphMargin: true, + dimension: { + width: this.props.colWidth * 4 - 30, + height: this.props.rowHeight * 6, + }, + minimap: { + enabled: false + } + } + + constructor(props:Props){ + super(props); + this.mountEditor = this.mountEditor.bind(this); + this.setText = this.setText.bind(this); + this.cucumberResult = this.cucumberResult.bind(this); + this.commentResult = this.commentResult.bind(this); + } + + cucumberResult(result:CucumberServiceResult){ + const step = this.state.steps.find(step => step instanceof Step && step.id === result.id); + if (step){ + let steps = this.state.steps; + const i = steps.findIndex(s => s === step); + let s = steps[i] as Step; + s.status = result.status; + this.setState({ + steps: steps + }) + } else { + this.setState((old) => ({ + steps: [...old.steps, new Step(result.stepVal, result.id, result.status)] + })); + } + this.forceUpdate(); + } + + commentResult(result:ServiceResult){ + this.setState((old) => ({ + steps: [...old.steps, new Comment(result.data)] + })); + } + + componentDidMount(){ + this.setState({ + steps: [ + new GherkinText("$feature", "Feature"), + new GherkinText("$scenario", "Scenario") + ] + }) + let cucumberSvc = Services.get().services.find(svc => svc instanceof CucumberService) as CucumberService; + if (cucumberSvc){ + cucumberSvc.dispatcher.register(this.cucumberResult); + } + let commentService = Services.get().services.find(svc => svc instanceof CommentService) as CommentService; + if (commentService){ + commentService.dispatcher.register(this.commentResult); + } + getVarService().dispatcher.register((_) => this.forceUpdate()); + } + + oldDecorations:string[]= []; + + refreshValue(){ + + } + + defineTheme(){ + monaco.editor.defineTheme('output-theme', { + base: 'vs', + inherit: true, + colors: {}, + rules: [ + {token: 'marginGlyphStatus-success', background: '00ff00'}, + {token: 'marginGlyphStatus-failure', background: 'ff0000'} + + ] + }) + } + + export(){ + this.setState(old => ( + { + steps: old.steps.filter(val => val instanceof Step ? val.status != ResultType.FAILURE : true) + } + )) + } + + mountEditor(e: monaco.editor.IStandaloneCodeEditor){ + this.setState({ + editor: e + }); + e.setModel(monaco.editor.createModel("", OUTPUT_ID)); + this.defineTheme(); + registerOutputEditorExtensions(e, this); + monaco.editor.setTheme('output-theme'); + } + + setText(){ + if (!this.state) + return; + let indent = 0; + const src : string[] = []; + this.state.steps.forEach(val => { + src.push('\t'.repeat(indent) + val.getLine().text); + if (val instanceof GherkinText){ + indent += 1; + } + }); + this.state.editor.setModel(monaco.editor.createModel(src.join("\n"), OUTPUT_ID)); + let line = 1; + let decorations : monaco.editor.IModelDeltaDecoration[] = []; + this.state.steps.forEach((val) => { + const l = val.getLine() + const deco = l.decorations; + const length = l.text.split("\n").length; + if (deco){ + decorations.push({ + range: new monaco.Range(line, 0, line, 0), + options: deco + }) + } + line += length; + }) + this.oldDecorations = this.state.editor.deltaDecorations(this.oldDecorations, decorations); + } + + + render(){ + this.setText(); + return
    +

    Feature output

    + +
    + } +} \ No newline at end of file diff --git a/ui/src/components/Step.tsx b/ui/src/components/Step.tsx index 9d6d753..f04deb3 100644 --- a/ui/src/components/Step.tsx +++ b/ui/src/components/Step.tsx @@ -1,5 +1,5 @@ import React, { Component } from "react"; -import { Step as CStep } from "../interop/cucumberTypes"; +import { IStep as CStep } from "../interop/cucumberTypes"; export class Step extends Component { diff --git a/ui/src/components/StepList.tsx b/ui/src/components/StepList.tsx index b675477..4c8f58e 100644 --- a/ui/src/components/StepList.tsx +++ b/ui/src/components/StepList.tsx @@ -1,25 +1,67 @@ -import React, { Component } from "react"; -import { Step as CStep } from "../interop/cucumberTypes"; -import { AppConfig } from "../interop/config"; +import React, { Component, ChangeEvent } from "react"; +import { IStep as CStep } from "../interop/cucumberTypes"; import { Step } from "./Step"; import { Loading } from "./Loading"; import { StepManager } from "../interop/stepManager"; +import Fuse from "fuse.js"; type StepState = { - steps: CStep[] + steps: CStep[], + search: string } export class StepList extends Component<{},StepState> { + constructor(props:any){ + super(props); + this.filter = this.filter.bind(this); + } + + searchOptions : Fuse.FuseOptions = { + shouldSort: true, + distance: 100, + minMatchCharLength: 1, + keys: [ + { + name: "pattern", + weight: 0.6 + }, { + name: "docs", + weight: 0.2 + }, { + name: "location", + weight: 0.2 + } + ] + } + componentDidMount(){ - StepManager.get().getSteps().then((steps) => this.setState({steps: steps})); + StepManager.get().getSteps().then((steps) => this.setState({steps: steps.map(step => step.toIStep())})); + } + + filter(e:ChangeEvent){ + this.setState({search: e.target.value}); } render(){ if (this.state){ - return (
      - {this.state.steps.map((val, i) => )} -
    ); + let steps = this.state.steps; + if (this.state.search){ + const fuse = new Fuse>(this.state.steps, this.searchOptions); + steps = fuse.search(this.state.search) as CStep[]; + } + return (
    +

    Step list

    + +
      + { + steps.length != 0 ? + steps.map((val : CStep, i : number) => ) + : No steps found + } +
    +
    ); + } else { return } diff --git a/ui/src/components/TerminalInput.tsx b/ui/src/components/TerminalInput.tsx index 3560107..13a38b4 100644 --- a/ui/src/components/TerminalInput.tsx +++ b/ui/src/components/TerminalInput.tsx @@ -2,14 +2,14 @@ import React, { Component, ChangeEvent } from "react"; import { CompletePrompt } from "./CompletePrompt"; import { StepManager } from "../interop/stepManager"; import { Dispatcher } from "flux"; -import { Step, Argument } from "../interop/cucumberTypes"; +import { IStep, Argument, Step } from "../interop/cucumberTypes"; export const keyDispatcher = new Dispatcher(); type State = { val:string, canSend:boolean, - stepRef?: Step, + stepRef?: IStep, parsedInput: (string | number)[] }; @@ -49,7 +49,7 @@ export class TerminalInput extends Component<{}, State> { } getStepRef(){ - return this.state.stepRef! as Step; + return this.state.stepRef! as IStep; } handleInput(input:React.KeyboardEvent){ @@ -111,15 +111,16 @@ export class TerminalInput extends Component<{}, State> { onSetStep(step: Step){ let split = []; if (step && step.args){ - split.push(step.pattern.substring(0, step.args[0].start!)); + const source = step.pattern.source; + split.push(source.substring(0, step.args[0].start!)); split.push(0); for (let i = 1; i < step.args.length; i++){ - split.push(step.pattern.substring(step.args[i-1].end! + 1, step.args[i].start!)); + split.push(source.substring(step.args[i-1].end! + 1, step.args[i].start!)); split.push(i); } - split.push(step.pattern.substring(step.args[step.args.length - 1].end! + 1)); + split.push(source.substring(step.args[step.args.length - 1].end! + 1)); } - this.setState({val: step ? step.pattern : "", parsedInput: split, stepRef: step}, () => { + this.setState({val: step ? step.pattern.source : "", parsedInput: split, stepRef: step.toIStep()}, () => { if (step && step.args){ const firstInput = document.getElementById('arg-0') as HTMLInputElement; if (firstInput){ diff --git a/ui/src/components/Toolbox.tsx b/ui/src/components/Toolbox.tsx new file mode 100644 index 0000000..aff08f9 --- /dev/null +++ b/ui/src/components/Toolbox.tsx @@ -0,0 +1,12 @@ +import React, { Component } from "react"; + +export class Toolbox extends Component { + + render(){ + return
    +

    Toolbox

    + +
    + } + +} \ No newline at end of file diff --git a/ui/src/editor/CommonFeatures.ts b/ui/src/editor/CommonFeatures.ts new file mode 100644 index 0000000..e4dc73f --- /dev/null +++ b/ui/src/editor/CommonFeatures.ts @@ -0,0 +1,20 @@ +import { register as registerFeatureLang } from "./FeatureHighlight"; +import { editor as e } from "monaco-editor"; +import { registerEditorActions } from "./EditorActions"; +import { addExportWidget } from "./OutputEditorActions"; +import { OutputEditor } from "../components/OutputEditor"; + +export const INPUT_ID = "feature"; +export const OUTPUT_ID = "feature-output"; + +export function registerCommonExtensions(){ + registerFeatureLang(); +} + +export function registerInputEditorExtensions(editor:e.IStandaloneCodeEditor){ + registerEditorActions(editor); +} + +export function registerOutputEditorExtensions(editor:e.IStandaloneCodeEditor, el:OutputEditor){ + addExportWidget(editor, el); +} \ No newline at end of file diff --git a/ui/src/editor/EditorActions.ts b/ui/src/editor/EditorActions.ts new file mode 100644 index 0000000..bea4a0d --- /dev/null +++ b/ui/src/editor/EditorActions.ts @@ -0,0 +1,144 @@ +import {editor as e, languages, editor} from "monaco-editor"; +import * as monaco from "monaco-editor"; +import { INPUT_ID } from "./CommonFeatures"; +import { Cucumber } from "../interop/Cucumber"; + +let runStepId : string; + +export function registerEditorActions(editor:e.IStandaloneCodeEditor){ + registerRunStep(editor); + registerRunAll(editor); + registerTest(editor); + addRunAllButton(editor); +} + +function registerRunStep(editor:e.IStandaloneCodeEditor){ + editor.addAction({ + contextMenuGroupId: INPUT_ID, + id: 'feature-run-step', + label: 'Run Cucumber Step', + keybindings: [ + monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter + ], + run: (e) => { + const selection = e.getSelection(); + const model = e.getModel(); + if (selection && model){ + Services.get().evaluate(model, selection.startLineNumber); + } + }, + + }); + + runStepId = editor.addCommand(0, (ctx, model, lineNum) => { + Services.get().evaluate(model, lineNum); + }, 'test')!; + + languages.registerCodeLensProvider(INPUT_ID, { + provideCodeLenses: (model, provider) => { + const matches = model.findMatches(Cucumber.STEP_PATTERN.source, true, true, false, " ", true); + if (matches){ + const lenses = matches.map((match) => ({ + range: match.range, + id: "Run-Step-CodeLens", + command: { + id: runStepId, + title: "Run Step", + tooltip: "Runs the step", + arguments: [model, match.range.startLineNumber] + } as languages.Command + })) + const result : languages.ProviderResult = { + lenses: lenses, + dispose: function (){}, + } + return result; + } + return null; + }, + resolveCodeLens: (model: editor.ITextModel, codeLens: languages.CodeLens, token: monaco.CancellationToken) => { + return codeLens; + } + }); +} +import { Services } from "../interop/Services"; +const runAllId = 'feature.run-all' +function registerRunAll(editor:e.IStandaloneCodeEditor){ + editor.addAction({ + contextMenuGroupId: INPUT_ID, + id: runAllId, + label: 'Run all', + keybindings: [ + monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter + ], + run: (e) => { + const model = e.getModel(); + if (model){ + let line = 1; + let failSafe = model.getLineCount(); + while (model.getLineCount() > 0 && model.getValue()){ + Services.get().evaluate(model, line); + failSafe--; + if (failSafe < 0) + break; + } + } + } + }) +} + +function registerTest(editor:e.IStandaloneCodeEditor){ + editor.addAction({ + id: 'feature-test', + label: 'Feature: test', + run: (e) => { + const model = e.getModel(); + + if (model){ + model.applyEdits([ + { + range: new monaco.Range(1, 0, 2, -1), + text: "" + } + ]) + } + } + }) +} + +class RunAllWidget implements e.IOverlayWidget { + + constructor(private editor:e.IStandaloneCodeEditor){} + + domNode?: HTMLElement + + getId(): string { + return 'feature.runall.widget'; + } + getDomNode(): HTMLElement { + if (!this.domNode){ + this.domNode = document.createElement("button"); + this.domNode.classList.add("overlay-button"); + this.domNode.innerHTML = "Run all steps" + this.domNode.style.right = '30px'; + const height = 32; + const offset = 5; + this.domNode.style.fontSize = "larger"; + this.domNode.style.height = `${height}px`; + this.domNode.style.top = this.editor.getDomNode()!.clientHeight - height - offset + "px"; + this.domNode.onclick = () => this.editor.getAction(runAllId).run() + } + return this.domNode!; + } + getPosition(): e.IOverlayWidgetPosition | null { + return null; + } + + +} + +function addRunAllButton(editor:e.IStandaloneCodeEditor){ + const runAll = new RunAllWidget(editor); + editor.addOverlayWidget(runAll); + +} \ No newline at end of file diff --git a/ui/src/editor/FeatureHighlight.ts b/ui/src/editor/FeatureHighlight.ts new file mode 100644 index 0000000..8b513b2 --- /dev/null +++ b/ui/src/editor/FeatureHighlight.ts @@ -0,0 +1,78 @@ +import * as monaco from "monaco-editor"; +import { languages, editor } from "monaco-editor" +import { StepManager } from "../interop/stepManager"; +import { INPUT_ID, OUTPUT_ID } from "./CommonFeatures"; + +export function register(){ + languages.register({id: INPUT_ID}); + languages.register({id: OUTPUT_ID}); + registerLexer(INPUT_ID); + registerLexer(OUTPUT_ID); + registerProvider(); +} + +function registerLexer(id: string){ + languages.setMonarchTokensProvider(id, { + defaultToken: 'invalid', + symbols: ['"', "'"], + tokenizer: { + root: [ + [/#.*$/, 'comment'], + [/@[\w\-]*/, 'annotation'], + [/(?:Feature|Scenario|Background):/, 'keyword', '@description'], + [/(?:Then|When|And|Given|But)/, 'keyword', '@step'], + [/\|/, 'delimiter', '@table'], + [/"""/, 'string', '@multilineString'] + ], + description: [ + [/.*/, 'identifier', '@pop'] + ], + table: [ + [/[^\|]/, 'string.table'], + [/\|\s*$/, 'delimiter', '@pop'], + [/\|/, 'delimiter'], + ], + step: [ + [/"[^"]*"$/, 'string', '@pop'], + [/\S$/, 'identifier', '@pop'], + [/\s$/, 'whitespace', '@pop'], + [/"[^"]*"/, 'string'], + [/\S/, 'identifier'], + [/\s/, 'whitespace'] + ], + multilineString: [ + [/.*"""/, 'string', '@pop'], + [/.*$/, 'string'], + ] + } + } as any); +} + +function registerProvider(){ + languages.registerCompletionItemProvider(INPUT_ID, { + async provideCompletionItems(model: editor.ITextModel, position: monaco.Position, context: monaco.languages.CompletionContext, token: monaco.CancellationToken){ + const steps = await StepManager.get().getSteps(); + const word = model.getWordUntilPosition(position); + const range : monaco.IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn + } + const suggestions: monaco.languages.CompletionList = { + suggestions: [ + ...steps.map((step) => ({ + label: step.pattern.source, + kind: languages.CompletionItemKind.Function, + insertText: step.getPlaceholderText(), + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: range + })) + ] + } + return suggestions; + } + }) +} + + diff --git a/ui/src/editor/OutputEditorActions.ts b/ui/src/editor/OutputEditorActions.ts new file mode 100644 index 0000000..d5d3149 --- /dev/null +++ b/ui/src/editor/OutputEditorActions.ts @@ -0,0 +1,39 @@ +import {editor as e} from "monaco-editor"; +import { OutputEditor } from "../components/OutputEditor"; + + +class ExportWidget implements e.IOverlayWidget { + + constructor(private editor:e.IStandaloneCodeEditor, private el:OutputEditor){} + + domNode?: HTMLElement + + getId(): string { + return 'feature-ex.export.widget'; + } + getDomNode(): HTMLElement { + if (!this.domNode){ + this.domNode = document.createElement("button"); + this.domNode.classList.add("overlay-button"); + this.domNode.innerHTML = "Export" + this.domNode.style.right = '30px'; + const height = 32; + const offset = 5; + this.domNode.style.fontSize = "larger"; + this.domNode.style.height = `${height}px`; + this.domNode.style.top = this.editor.getDomNode()!.clientHeight - height - offset + "px"; + this.domNode.onclick = () => this.el.export(); + } + return this.domNode!; + } + getPosition(): e.IOverlayWidgetPosition | null { + return null; + } + + +} + +export function addExportWidget(editor:e.IStandaloneCodeEditor, el:OutputEditor){ + const exportW = new ExportWidget(editor, el); + editor.addOverlayWidget(exportW); +} \ No newline at end of file diff --git a/ui/src/interop/Cucumber.ts b/ui/src/interop/Cucumber.ts new file mode 100644 index 0000000..ab772a3 --- /dev/null +++ b/ui/src/interop/Cucumber.ts @@ -0,0 +1,49 @@ +import { Step } from "./cucumberTypes"; +import { StepManager } from "./stepManager"; + +export class Cucumber { + + public static DATATABLE_TYPE = "cucumber.api.DataTable"; + public static STEP_PATTERN = /(?:Given|When|Then|And|But)\s(.*)/; + + /** + * @returns true if str is a step + */ + public static isStep(str:string):boolean{ + const line = str.trim(); + if (line.length <= 0){ + return false; + } + if (this.STEP_PATTERN.test(str)){ + return true; + } + return false; + } + + public static extractValue(str:string):string { + const match = str.trim().match(this.STEP_PATTERN); + if (match){ + return match[1]; + } + return ""; + } + + /** + * Takes step with all the fuss around that is not in the definition and finds the right + * step definition for it + * @param str Actual step + */ + public static findRightStep(str:string):Step | undefined{ + if (Cucumber.isStep(str)){ + const actualStep = Cucumber.extractValue(str); + const step = StepManager.get().getStepsSync().find((val) => { + const match = actualStep.match(val.pattern) + if (match && match[0] === actualStep){ + return val; + } + }); + return step; + } + } + +} \ No newline at end of file diff --git a/ui/src/interop/Services.ts b/ui/src/interop/Services.ts new file mode 100644 index 0000000..506384f --- /dev/null +++ b/ui/src/interop/Services.ts @@ -0,0 +1,59 @@ +import { CucumberService } from "./services/CucumberService"; +import { UnknownOpService } from "./services/UnknownOpService"; +import { Service, Model, ServiceResult } from "./services/Service"; +import { CommentService } from "./services/CommentService"; +import { VariableService } from "./services/VariableService"; + +export type Subscription = { + type: typeof Service, + callback:(arg:ServiceResult) => void +} + +export class Services { + + services:Service[] = [] + noopService:Service + static instance:Services; + subscriptions:Subscription[] = []; + + constructor(){ + this.registerServices(); + this.noopService = this.registerService(new UnknownOpService()); + } + + private registerService(svc:Service) : Service{ + this.services.push(svc); + return svc; + } + + static get(){ + if (!this.instance){ + this.instance = new Services(); + } + return this.instance; + } + + registerServices(){ + this.registerService(new CucumberService()); + this.registerService(new CommentService()); + this.registerService(new VariableService()); + } + + getService(type : typeof Service): Service | undefined { + return this.services.find(svc => svc instanceof type); + } + + + evaluate(model:Model, from:number){ + const service = this.services.find(s => s.canHandleModel(model, from)); + console.debug(service); + if (service){ + service.handle(model, from); + } else { + this.noopService.handle(model, from); + } + } + + + +} \ No newline at end of file diff --git a/ui/src/interop/config.ts b/ui/src/interop/config.ts index c898e47..3b44c4b 100644 --- a/ui/src/interop/config.ts +++ b/ui/src/interop/config.ts @@ -3,7 +3,6 @@ export class AppConfig { static getServerUrl():string { - //TODO:add actual value return `http://localhost:${this.getServerPort()}`; } diff --git a/ui/src/interop/cucumberTypes.ts b/ui/src/interop/cucumberTypes.ts index 55d78e0..f0ca34b 100644 --- a/ui/src/interop/cucumberTypes.ts +++ b/ui/src/interop/cucumberTypes.ts @@ -1,3 +1,7 @@ +import { Cucumber } from "./Cucumber"; + +export const DataTableClassName = "cucumber.api.DataTable" + export type Argument = { type: string, suggProvider: string @@ -5,9 +9,90 @@ export type Argument = { end?: number } -export type Step = { - pattern: string, - args?: Argument[], - location?: string, - docs?: string, -} \ No newline at end of file +export type IStep = { + pattern: string; + args?: Argument[]; + location?: string; + docs?: string; +} + +export class Step { + pattern: RegExp; + args?: Argument[]; + location?: string; + docs?: string; + + constructor(pattern:string, args?:Argument[], location?:string, docs?:string){ + this.pattern = new RegExp(pattern); + this.location = location; + this.docs = docs; + this.args = this.analyzeArgs(pattern, args); + } + + static fromIStep(istep:IStep):Step{ + return new Step(istep.pattern, istep.args, istep.location, istep.docs); + } + + toIStep():IStep{ + return { + ...this, + pattern: this.pattern.source + }; + } + + analyzeArgs(pattern:String, args?:Argument[]):Argument[]{ + if (args){ + let params = args; + let start = 0; + let end = 0; + let parN = 0; + for (let i = 1; i < pattern.length; i++){ + if (pattern[i] === '(' && pattern[i-1] !== '\\' && pattern[i+1] !== '?'){ + start = i; + } + if (pattern[i] === ')' && pattern[i-1] !== '\\'){ + end = i; + params[parN].start = start; + params[parN].end = end; + parN++; + } + } + return params; + } else { + return []; + } + } + + analyzeParams(steps:Step[]){ + return steps.map((step) => { + if (step.args){ + + return step; + } else { + return step; + } + }) + } + + getPlaceholderText():string{ + const args = this.args; + if (args){ + const source = this.pattern.source; + let ret = []; + ret.push(source.substring(0, args[0].start!)); + ret.push(`\${1:${source.substring(args[0].start!, args[0].end!+1)}}`); + + for (let i = 1; i < args.length; i++){ + if (args[i].type === Cucumber.DATATABLE_TYPE || !args[i].start){ + break; + } + ret.push(source.substring(args[i-1].end! + 1, args[i].start!)); + ret.push(`\${${i+1}:${source.substring(args[i].start!, args[i].end!+1)}}`); + } + ret.push(source.substring(args[args.length - 1].end! + 1)); + return ret.join(""); + } + return this.pattern.source; + + } +} diff --git a/ui/src/interop/historyManager.ts b/ui/src/interop/historyManager.ts deleted file mode 100644 index 07c32cb..0000000 --- a/ui/src/interop/historyManager.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Dispatcher } from "flux"; -import { Step } from "./cucumberTypes"; -import { HistoryResult, ResultType } from "./feedback"; - -export type HistoryEntry = { - id: number, - step: string, - status: ResultType -} - -export class HistoryManager { - - static instance : HistoryManager; - public historyDispatcher : Dispatcher = new Dispatcher(); - public history : HistoryEntry[] = []; - - public static get(){ - if (!this.instance){ - this.instance = new HistoryManager(); - } - return this.instance; - } - - public report(status:{step: string, status: ResultType}):number{ - const id = this.history.length; - this.history.push({ - ...status, - id: id - }); - this.historyDispatcher.dispatch(this.history[id]); - return id; - } - - public reportForId(id: number, status: ResultType){ - this.history[id].status = status; - this.historyDispatcher.dispatch(this.history[id]); - } - - public getScenario(){ - let template = `Feature: $feature \n Scenario: $scenario \n `; - for (let h of this.history){ - template += `\tWhen ${h.step}\n`; - } - return template; - } - -} \ No newline at end of file diff --git a/ui/src/interop/services/CommentService.ts b/ui/src/interop/services/CommentService.ts new file mode 100644 index 0000000..9ef9942 --- /dev/null +++ b/ui/src/interop/services/CommentService.ts @@ -0,0 +1,19 @@ +import { Service, ServiceResult, Model } from "./Service"; +import { ResultType } from "../feedback"; + +export class CommentService extends Service { + canHandle(line: string): boolean { + return line.trim().startsWith("#"); + } + + handle(model: Model, from: number): ResultType { + const line = this.consumeLine(model, from); + this.dispatcher.dispatch({ + status: ResultType.SUCCESS, + data: line + }); + return ResultType.SUCCESS; + } + + +} \ No newline at end of file diff --git a/ui/src/interop/services/CucumberService.ts b/ui/src/interop/services/CucumberService.ts new file mode 100644 index 0000000..5f194e4 --- /dev/null +++ b/ui/src/interop/services/CucumberService.ts @@ -0,0 +1,71 @@ +import { Cucumber } from "../Cucumber"; +import { StepManager } from "../stepManager"; +import { Service, Model, ServiceResult } from "./Service"; +import { ResultType } from "../feedback"; +import { Step, IStep } from "../cucumberTypes"; + +export interface CucumberServiceResult extends ServiceResult { + stepDef?: IStep, + stepVal: string, + id: number +} + +export class CucumberService extends Service { + + static id:number = 0; + + canHandle(line: string): boolean { + return Cucumber.STEP_PATTERN.test(line); + } + + handle(model: Model, from:number) : ResultType { + const stepDef = Cucumber.findRightStep(this.peek(model, from)); + if (stepDef){ + const stepLine = this.consumeLine(model, from); + let step = Cucumber.extractValue(stepLine); + if (stepDef.args) { + const args = stepDef.args; + const lastArg = args[args.length - 1]; + if (lastArg.type === Cucumber.DATATABLE_TYPE) { + //Consuming data table + while(this.peek(model, from).trim().startsWith("|")){ + const tableLine = this.consumeLine(model, from); + step += `\n${tableLine}`; + } + } else if (lastArg.type === "java.lang.String" && !lastArg.start){ + //Consuming doc string + if (this.peek(model, from).trim().startsWith('"""')){ + const startString = this.consumeLine(model, from); + step += `\n${startString}`; + while (!this.peek(model, from).trim().startsWith('"""')){ + const str = this.consumeLine(model, from); + step += `\n${str}`; + } + if (this.peek(model, from).trim().startsWith('"""')){ + const endString = this.consumeLine(model, from); + step += `\n${endString}`; + } + } + } + } + let event = { + service: this, + stepDef: stepDef.toIStep(), + stepVal: stepLine, + data: step, + id: CucumberService.id++ + }; + StepManager.get().runStep(step).then((result) => { + this.dispatcher.dispatch({...event, status: result}) + }); + this.dispatcher.dispatch({...event, status: ResultType.WAITING}); + return ResultType.WAITING; + } else { + //TODO give feedback about unknown step + const line = this.consumeLine(model, from); + this.dispatcher.dispatch({id: CucumberService.id++, stepVal: line, status: ResultType.FAILURE}); + console.warn(`${line} is not a valid step`) + return ResultType.FAILURE; + } + } +} \ No newline at end of file diff --git a/ui/src/interop/services/Service.ts b/ui/src/interop/services/Service.ts new file mode 100644 index 0000000..d0074a7 --- /dev/null +++ b/ui/src/interop/services/Service.ts @@ -0,0 +1,50 @@ +import * as monaco from "monaco-editor"; +import { ResultType } from "../feedback"; +import { Dispatcher } from "flux"; + +export type Model = monaco.editor.IModel; + +export interface ServiceResult { + status: ResultType, + data?: any, +} + +export abstract class Service { + + dispatcher:Dispatcher = new Dispatcher(); + + abstract canHandle(line: string): boolean; + //TODO maybe this should return some result? + abstract handle(model: Model, from: number):ResultType; + + canHandleModel(model: Model, from: number): boolean { + return this.canHandle(this.peek(model, from)); + } + + consumeLines(model: Model, count: number, from: number = 1): string[] { + const ret = model.getLinesContent().slice(from, count); + model.applyEdits([ + { + range: new monaco.Range(from, 0, from + count, -1), + text: "" + } + ]); + return ret; + } + + consumeLine(model: Model, from: number = 1): string { + const ret = model.getLineContent(from); + model.applyEdits([ + { + range: new monaco.Range(from, 0, from + 1, -1), + text: "" + } + ]); + return ret; + } + + peek(model: Model, line: number = 1): string { + return model.getLineContent(line); + } + +} \ No newline at end of file diff --git a/ui/src/interop/services/UnknownOpService.ts b/ui/src/interop/services/UnknownOpService.ts new file mode 100644 index 0000000..e279660 --- /dev/null +++ b/ui/src/interop/services/UnknownOpService.ts @@ -0,0 +1,15 @@ +import { Service, Model, ServiceResult } from "./Service"; +import { ResultType } from "../feedback"; + +export class UnknownOpService extends Service { + canHandle(line: string): boolean { + return true; + } + + handle(model: Model, from: number): ResultType { + this.consumeLine(model, from); + return ResultType.SUCCESS; + } + + +} \ No newline at end of file diff --git a/ui/src/interop/services/VariableService.ts b/ui/src/interop/services/VariableService.ts new file mode 100644 index 0000000..643cb86 --- /dev/null +++ b/ui/src/interop/services/VariableService.ts @@ -0,0 +1,38 @@ +import { Service, Model, ServiceResult } from "./Service"; +import { ResultType } from "../feedback"; + +export class VariableService extends Service { + + VARIABLE_STATEMENT = /(\w+):\s*(.*)$/; + + variables: {[key:string]:string} = {}; + + canHandle(line: string): boolean { + return this.VARIABLE_STATEMENT.test(line); + } + + getValue(key:string){ + return this.variables[key.toLowerCase()]; + } + + handle(model: Model, from: number): ResultType { + const line = this.consumeLine(model, from); + const res = this.VARIABLE_STATEMENT.exec(line); + if (res){ + const name = res[1].toLowerCase(); + const val = res[2]; + this.variables[name] = val; + this.dispatcher.dispatch({ + status: ResultType.SUCCESS, + data: val + }); + return ResultType.SUCCESS + } else { + return ResultType.FAILURE; + } + } + + + + +} \ No newline at end of file diff --git a/ui/src/interop/stepManager.ts b/ui/src/interop/stepManager.ts index d2da15f..10b9f47 100644 --- a/ui/src/interop/stepManager.ts +++ b/ui/src/interop/stepManager.ts @@ -1,9 +1,7 @@ import { AppConfig } from "./config"; -import { Step } from "./cucumberTypes"; +import { Step, IStep } from "./cucumberTypes"; import { Result, ResultType, HistoryResult } from "./feedback"; import { Dispatcher } from "flux"; -import { HistoryManager } from "./historyManager"; - export class StepManager { @@ -29,31 +27,11 @@ export class StepManager { }) } - analyzeParams(steps:Step[]){ - return steps.map((step) => { - if (step.args){ - let params = step.args; - let start = 0; - let end = 0; - let parN = 0; - for (let i = 1; i < step.pattern.length; i++){ - if (step.pattern[i] === '(' && step.pattern[i-1] !== '\\' && step.pattern[i+1] !== '?'){ - start = i; - } - if (step.pattern[i] === ')' && step.pattern[i-1] !== '\\'){ - end = i; - params[parN].start = start; - params[parN].end = end; - parN++; - } - } - return step; - } else { - return step; - } - }) + getStepsSync():Step[]{ + return this.stepRepo; } + getSuggestionForArg(step:Step, i:number, stepArgs:string[] = []):Promise<{val: string}[]> | undefined{ if (step.args){ const args = step.args; @@ -73,7 +51,6 @@ export class StepManager { argId: i }) }).then((r => r.json())).then((suggs:string[]) => { - console.log(suggs.map((v) => ({val: v}))); resolve(suggs.map((v) => ({val: v}))) }) } @@ -85,8 +62,8 @@ export class StepManager { } fetchSteps(callback:(value?:Step[]) => void | undefined){ - fetch(`${AppConfig.getServerUrl()}/liststeps`).then((r) => r.json()).then((steps:Step[]) => { - this.stepRepo = this.analyzeParams(steps); + fetch(`${AppConfig.getServerUrl()}/liststeps`).then((r) => r.json()).then((steps:IStep[]) => { + this.stepRepo = steps.map(Step.fromIStep); if (callback){ callback(this.stepRepo); } @@ -94,20 +71,19 @@ export class StepManager { }) } - runStep(step: string) { - const id = HistoryManager.get().report({ - status: ResultType.WAITING, - step: step, - }); - fetch(`${AppConfig.getServerUrl()}/runstep`, { - body: step, - method: "POST", - }).then().then((res) => { - if (res.ok){ - HistoryManager.get().reportForId(id, ResultType.SUCCESS); - } else { - HistoryManager.get().reportForId(id, ResultType.FAILURE); - } - }); + runStep(step: string):Promise { + return new Promise((resolve) => { + fetch(`${AppConfig.getServerUrl()}/runstep`, { + body: step, + method: "POST", + }).then().then((res) => { + if (res.ok){ + resolve(ResultType.SUCCESS); + } else { + resolve(ResultType.FAILURE); + } + }); + }) + } } \ No newline at end of file diff --git a/ui/src/styles/editors.css b/ui/src/styles/editors.css new file mode 100644 index 0000000..6c32b65 --- /dev/null +++ b/ui/src/styles/editors.css @@ -0,0 +1,14 @@ +.overlay-button { + opacity: 20%; + padding: 5px; + cursor: pointer; + background: whitesmoke; + border: 2px solid #555c63; + border-radius: 5%; + transition: 0.5s; +} + +.overlay-button:hover{ + opacity: 100%; + color: #29649e; +} \ No newline at end of file diff --git a/ui/src/styles/log.css b/ui/src/styles/log.css new file mode 100644 index 0000000..3893952 --- /dev/null +++ b/ui/src/styles/log.css @@ -0,0 +1,35 @@ +.logger-error { + color: #ff0000; + font-weight: bold; +} + +.logger-warn { + color: #d46708; +} + +.logger-info { + color: #3dbf62; +} + +.logger-debug { + color: #3dbf62; +} + +.logger-std { + color: #3dbf62; +} + +.logger-stderr { + color: #ff0000; + font-weight: bold; +} + +#logger { + overflow: scroll; + height: inherit; + border-top: 2px solid #cccccc; +} + +#logger > h3 { + display: inline; +} \ No newline at end of file diff --git a/ui/src/styles/outputEditor.css b/ui/src/styles/outputEditor.css new file mode 100644 index 0000000..703bf62 --- /dev/null +++ b/ui/src/styles/outputEditor.css @@ -0,0 +1,10 @@ +.marginGlyphStatus-success{ + background: #00ff00; +} +.marginGlyphStatus-waiting{ + background: orange; +} + +.marginGlyphStatus-failure{ + background: #ff0000; +} \ No newline at end of file diff --git a/ui/src/styles/style.module.css b/ui/src/styles/style.module.css index a53160f..ab2d74d 100644 --- a/ui/src/styles/style.module.css +++ b/ui/src/styles/style.module.css @@ -4,4 +4,4 @@ font-family: Arial; text-align: center; } - \ No newline at end of file + diff --git a/ui/tsconfig.json b/ui/tsconfig.json index f2850b7..5ae6938 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -1,11 +1,13 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "lib": [ "dom", "dom.iterable", "esnext" ], + "outDir": "./dist/", + "sourceMap": true, "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -16,8 +18,7 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true, - "jsx": "react" + "jsx": "react", }, "include": [ "src" diff --git a/ui/webpack.config.js b/ui/webpack.config.js new file mode 100644 index 0000000..22809a7 --- /dev/null +++ b/ui/webpack.config.js @@ -0,0 +1,75 @@ +const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin"); +const path = require("path"); +const HtmlWebPackPlugin = require("html-webpack-plugin"); + +// const MonacoEditorSrc = path.join(__dirname, "..", "src"); + +module.exports = { + entry: "./src/index.tsx", + mode: "development", + devtool: "source-map", + output: { + path: path.join(__dirname, "./dist"), + filename: "index.js" + }, + module: { + rules: [ + { + test: /\.ts(x?)$/, + exclude: /node_modules/, + use: [ + { + loader: "ts-loader" + } + ] + }, + { + test: /\.html$/, + use: [ + { + loader: "html-loader" + } + ] + }, + { + test: /\.(js|jsx)$/, + exclude: /node_modules/, + use: [ + { + loader: "babel-loader", + options: { + presets: ["@babel/preset-env", "@babel/preset-react"], + plugins: ["@babel/plugin-proposal-class-properties"] + } + } + ] + }, + { + test: /\.css$/, + use: ["style-loader", "css-loader"] + }, + { + test: /\.ttf$/, + use: ["file-loader"] + } + ] + }, + resolve: { + extensions: [".js", ".json", ".ts", ".tsx"], + // Remove alias until https://github.com/microsoft/monaco-editor-webpack-plugin/issues/68 is fixed + //alias: { "react-monaco-editor": MonacoEditorSrc } + }, + plugins: [ + new MonacoWebpackPlugin({ + languages: ["json", "javascript", "typescript"] + }), + new HtmlWebPackPlugin({ + template: "./public/index.html", + filename: "./index.html" + }) + ], + devServer: { contentBase: "./" }, + node: { + fs: "empty" + } +};