├── .gitignore ├── README.md ├── config-overrides.js ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── main.js │ ├── search.js │ ├── style.css │ ├── widgets.png │ └── widgets@2x.png ├── classes │ ├── BrowserPool.html │ ├── RemoteBrowser.html │ ├── WorkflowGenerator.html │ └── WorkflowInterpreter.html ├── enums │ ├── ActionType.html │ └── TagName.html ├── functions │ ├── createRemoteBrowserForRun.html │ ├── createSocketConnection.html │ ├── createSocketConnectionForRun.html │ ├── deleteFile.html │ ├── destroyRemoteBrowser.html │ ├── getActiveBrowserId.html │ ├── getBestSelectorForAction.html │ ├── getElementInformation.html │ ├── getRect.html │ ├── getRemoteBrowserCurrentTabs.html │ ├── getRemoteBrowserCurrentUrl.html │ ├── getSelectors.html │ ├── initializeRemoteBrowserForRecording.html │ ├── interpretWholeWorkflow.html │ ├── isRuleOvershadowing.html │ ├── readFile.html │ ├── readFiles.html │ ├── saveFile.html │ ├── selectorAlreadyInWorkflow.html │ └── stopRunningInterpretation.html ├── index.html ├── interfaces │ ├── BaseAction.html │ ├── BaseActionInfo.html │ ├── Coordinates.html │ ├── InterpreterSettings.html │ ├── KeyboardInput.html │ ├── Rectangle.html │ ├── RemoteBrowserOptions.html │ ├── ScrollDeltas.html │ └── Selectors.html ├── modules.html ├── types │ ├── Action.html │ └── PossibleOverShadow.html └── variables │ ├── browserPool.html │ └── io.html ├── examples ├── recordings │ ├── alza-output.waw.json │ ├── alza-scrape.waw.json │ ├── screenshot.waw.json │ └── seznam.waw.json └── runs │ └── cockatiel_a684b626-e458-4f65-9e85-124b4efab7f3.json ├── package.json ├── public ├── img │ ├── recording-main.png │ └── recordings.png └── index.html ├── server └── src │ ├── browser-management │ ├── classes │ │ ├── BrowserPool.ts │ │ └── RemoteBrowser.ts │ ├── controller.ts │ └── inputHandlers.ts │ ├── constants │ └── config.ts │ ├── index.ts │ ├── logger.ts │ ├── routes │ ├── index.ts │ ├── record.ts │ ├── storage.ts │ └── workflow.ts │ ├── server.ts │ ├── socket-connection │ └── connection.ts │ ├── types │ └── index.ts │ └── workflow-management │ ├── classes │ ├── Generator.ts │ └── Interpreter.ts │ ├── selector.ts │ ├── storage.ts │ └── utils.ts ├── src ├── App.test.tsx ├── App.tsx ├── api │ ├── recording.ts │ ├── storage.ts │ └── workflow.ts ├── components │ ├── atoms │ │ ├── AlertSnackbar.tsx │ │ ├── Box.tsx │ │ ├── DropdownMui.tsx │ │ ├── GenericModal.tsx │ │ ├── Highlighter.tsx │ │ ├── KeyValuePair.tsx │ │ ├── Loader.tsx │ │ ├── PairDisplayDiv.tsx │ │ ├── RecorderIcon.tsx │ │ ├── buttons │ │ │ ├── AddButton.tsx │ │ │ ├── BreakpointButton.tsx │ │ │ ├── ClearButton.tsx │ │ │ ├── EditButton.tsx │ │ │ ├── RemoveButton.tsx │ │ │ └── buttons.tsx │ │ ├── canvas.tsx │ │ ├── form.tsx │ │ └── texts.tsx │ ├── molecules │ │ ├── ActionSettings.tsx │ │ ├── AddWhatCondModal.tsx │ │ ├── AddWhereCondModal.tsx │ │ ├── BrowserNavBar.tsx │ │ ├── BrowserTabs.tsx │ │ ├── ColapsibleRow.tsx │ │ ├── DisplayWhereConditionSettings.tsx │ │ ├── InterpretationButtons.tsx │ │ ├── InterpretationLog.tsx │ │ ├── KeyValueForm.tsx │ │ ├── LeftSidePanelContent.tsx │ │ ├── LeftSidePanelSettings.tsx │ │ ├── NavBar.tsx │ │ ├── Pair.tsx │ │ ├── PairDetail.tsx │ │ ├── PairEditForm.tsx │ │ ├── RecordingsTable.tsx │ │ ├── RunContent.tsx │ │ ├── RunSettings.tsx │ │ ├── RunsTable.tsx │ │ ├── SaveRecording.tsx │ │ ├── SidePanelHeader.tsx │ │ ├── ToggleButton.tsx │ │ ├── UrlForm.tsx │ │ └── action-settings │ │ │ ├── clickOnCoordinates.tsx │ │ │ ├── enqueueLinks.tsx │ │ │ ├── index.ts │ │ │ ├── scrape.tsx │ │ │ ├── scrapeSchema.tsx │ │ │ ├── screenshot.tsx │ │ │ ├── script.tsx │ │ │ └── scroll.tsx │ └── organisms │ │ ├── BrowserContent.tsx │ │ ├── BrowserWindow.tsx │ │ ├── LeftSidePanel.tsx │ │ ├── MainMenu.tsx │ │ ├── Recordings.tsx │ │ ├── RightSidePanel.tsx │ │ └── Runs.tsx ├── constants │ └── const.ts ├── context │ ├── browserDimensions.tsx │ ├── globalInfo.tsx │ └── socket.tsx ├── helpers │ └── inputHelpers.ts ├── index.css ├── index.tsx ├── pages │ ├── MainPage.tsx │ ├── PageWrappper.tsx │ └── RecordingPage.tsx └── shared │ ├── constants.ts │ └── types.ts ├── thesis.pdf ├── tsconfig.json └── typedoc.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # misc 5 | .DS_Store 6 | .env.local 7 | .env.development.local 8 | .env.test.local 9 | .env.production.local 10 | 11 | /.idea 12 | 13 | /server/logs 14 | 15 | /build 16 | 17 | package-lock.json 18 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | /* config-overrides.js */ 2 | 3 | module.exports = function override(config, env) { 4 | //do stuff with the webpack config... 5 | return { 6 | ...config, 7 | resolve: { 8 | ...config.resolve, 9 | fallback: { 10 | ...config.resolve.fallback, 11 | path: false, 12 | }, 13 | }, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #000000; 3 | --dark-hl-0: #D4D4D4; 4 | --light-hl-1: #0451A5; 5 | --dark-hl-1: #9CDCFE; 6 | --light-hl-2: #A31515; 7 | --dark-hl-2: #CE9178; 8 | --light-hl-3: #098658; 9 | --dark-hl-3: #B5CEA8; 10 | --light-code-background: #FFFFFF; 11 | --dark-code-background: #1E1E1E; 12 | } 13 | 14 | @media (prefers-color-scheme: light) { :root { 15 | --hl-0: var(--light-hl-0); 16 | --hl-1: var(--light-hl-1); 17 | --hl-2: var(--light-hl-2); 18 | --hl-3: var(--light-hl-3); 19 | --code-background: var(--light-code-background); 20 | } } 21 | 22 | @media (prefers-color-scheme: dark) { :root { 23 | --hl-0: var(--dark-hl-0); 24 | --hl-1: var(--dark-hl-1); 25 | --hl-2: var(--dark-hl-2); 26 | --hl-3: var(--dark-hl-3); 27 | --code-background: var(--dark-code-background); 28 | } } 29 | 30 | :root[data-theme='light'] { 31 | --hl-0: var(--light-hl-0); 32 | --hl-1: var(--light-hl-1); 33 | --hl-2: var(--light-hl-2); 34 | --hl-3: var(--light-hl-3); 35 | --code-background: var(--light-code-background); 36 | } 37 | 38 | :root[data-theme='dark'] { 39 | --hl-0: var(--dark-hl-0); 40 | --hl-1: var(--dark-hl-1); 41 | --hl-2: var(--dark-hl-2); 42 | --hl-3: var(--dark-hl-3); 43 | --code-background: var(--dark-code-background); 44 | } 45 | 46 | .hl-0 { color: var(--hl-0); } 47 | .hl-1 { color: var(--hl-1); } 48 | .hl-2 { color: var(--hl-2); } 49 | .hl-3 { color: var(--hl-3); } 50 | pre, code { background: var(--code-background); } 51 | -------------------------------------------------------------------------------- /docs/assets/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sauermar/web-browser-recorder/0590be482fb9686acb7a070f2d9b2b5fb26bb12f/docs/assets/widgets.png -------------------------------------------------------------------------------- /docs/assets/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sauermar/web-browser-recorder/0590be482fb9686acb7a070f2d9b2b5fb26bb12f/docs/assets/widgets@2x.png -------------------------------------------------------------------------------- /examples/recordings/alza-output.waw.json: -------------------------------------------------------------------------------- 1 | { 2 | "recording_meta": { 3 | "name": "alza-output", 4 | "create_date": "7/15/2022, 3:14:20 AM", 5 | "pairs": 2, 6 | "update_date": "7/15/2022, 3:14:20 AM", 7 | "params": [] 8 | }, 9 | "recording": { 10 | "workflow": [ 11 | { 12 | "where": { 13 | "url": "https://www.alza.cz/hobby/zahradni-bazeny/18890782.htm" 14 | }, 15 | "what": [ 16 | { 17 | "action": "scrape", 18 | "args": [] 19 | }, 20 | { 21 | "action": "waitForLoadState", 22 | "args": [ 23 | "networkidle" 24 | ] 25 | }, 26 | { 27 | "action": "scroll", 28 | "args": [ 29 | 1 30 | ] 31 | }, 32 | { 33 | "action": "waitForLoadState", 34 | "args": [ 35 | "networkidle" 36 | ] 37 | }, 38 | { 39 | "action": "screenshot", 40 | "args": [ 41 | { 42 | "type": "jpeg", 43 | "quality": 75 44 | } 45 | ] 46 | } 47 | ] 48 | }, 49 | { 50 | "where": { 51 | "url": "about:blank" 52 | }, 53 | "what": [ 54 | { 55 | "action": "goto", 56 | "args": [ 57 | "https://www.alza.cz/hobby/zahradni-bazeny/18890782.htm" 58 | ] 59 | }, 60 | { 61 | "action": "waitForLoadState", 62 | "args": [ 63 | "networkidle" 64 | ] 65 | } 66 | ] 67 | } 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /examples/recordings/alza-scrape.waw.json: -------------------------------------------------------------------------------- 1 | { 2 | "recording_meta": { 3 | "name": "alza-scrape", 4 | "create_date": "7/15/2022, 2:30:31 AM", 5 | "pairs": 4, 6 | "update_date": "7/15/2022, 4:24:14 AM", 7 | "params": [] 8 | }, 9 | "recording": { 10 | "workflow": [ 11 | { 12 | "where": { 13 | "url": "https://www.alza.cz/sport/elektricke-kolobezky/18859663.htm" 14 | }, 15 | "what": [ 16 | { 17 | "action": "scrape", 18 | "args": [] 19 | }, 20 | { 21 | "action": "waitForLoadState", 22 | "args": [ 23 | "networkidle" 24 | ] 25 | }, 26 | { 27 | "action": "fill", 28 | "args": [ 29 | { 30 | "$param": "name" 31 | } 32 | ] 33 | } 34 | ] 35 | }, 36 | { 37 | "where": { 38 | "url": "https://www.alza.cz/sport/elektricke-kolobezky/18859663.htm", 39 | "selectors": [ 40 | "[href=\"javascript:Alza.Web.Cookies.acceptAllCookies();\"]" 41 | ] 42 | }, 43 | "what": [ 44 | { 45 | "action": "click", 46 | "args": [ 47 | "[href=\"javascript:Alza.Web.Cookies.acceptAllCookies();\"]" 48 | ] 49 | }, 50 | { 51 | "action": "waitForLoadState", 52 | "args": [ 53 | "networkidle" 54 | ] 55 | } 56 | ] 57 | }, 58 | { 59 | "where": { 60 | "url": "https://www.alza.cz/sport/elektricke-kolobezky/18859663.htm", 61 | "selectors": [ 62 | "#alzaDialog > .close" 63 | ] 64 | }, 65 | "what": [ 66 | { 67 | "action": "click", 68 | "args": [ 69 | "#alzaDialog > .close" 70 | ] 71 | }, 72 | { 73 | "action": "waitForLoadState", 74 | "args": [ 75 | "networkidle" 76 | ] 77 | } 78 | ] 79 | }, 80 | { 81 | "where": { 82 | "url": "about:blank" 83 | }, 84 | "what": [ 85 | { 86 | "action": "goto", 87 | "args": [ 88 | "https://www.alza.cz/sport/elektricke-kolobezky/18859663.htm" 89 | ] 90 | }, 91 | { 92 | "action": "waitForLoadState", 93 | "args": [ 94 | "networkidle" 95 | ] 96 | } 97 | ] 98 | } 99 | ] 100 | } 101 | } -------------------------------------------------------------------------------- /examples/recordings/screenshot.waw.json: -------------------------------------------------------------------------------- 1 | { 2 | "recording_meta": { 3 | "name": "screenshot", 4 | "create_date": "7/14/2022, 9:28:47 PM", 5 | "pairs": 2, 6 | "update_date": "7/14/2022, 9:28:47 PM", 7 | "params": [] 8 | }, 9 | "recording": { 10 | "workflow": [ 11 | { 12 | "where": { 13 | "url": "https://www.wikipedia.org/", 14 | "selectors": [ 15 | "#js-link-box-en" 16 | ] 17 | }, 18 | "what": [ 19 | { 20 | "action": "click", 21 | "args": [ 22 | "#js-link-box-en" 23 | ] 24 | }, 25 | { 26 | "action": "waitForLoadState", 27 | "args": [ 28 | "networkidle" 29 | ] 30 | }, 31 | { 32 | "action": "screenshot", 33 | "args": [] 34 | } 35 | ] 36 | }, 37 | { 38 | "where": { 39 | "url": "about:blank" 40 | }, 41 | "what": [ 42 | { 43 | "action": "goto", 44 | "args": [ 45 | "https://wikipedia.org" 46 | ] 47 | }, 48 | { 49 | "action": "waitForLoadState", 50 | "args": [ 51 | "networkidle" 52 | ] 53 | } 54 | ] 55 | } 56 | ] 57 | } 58 | } -------------------------------------------------------------------------------- /examples/recordings/seznam.waw.json: -------------------------------------------------------------------------------- 1 | { 2 | "recording_meta": { 3 | "name": "seznam", 4 | "create_date": "6/20/2022, 4:33:28 PM", 5 | "pairs": 4, 6 | "update_date": "6/20/2022, 4:33:28 PM" 7 | }, 8 | "recording": { 9 | "workflow": [ 10 | { 11 | "where": { 12 | "url": "https://www.novinky.cz/", 13 | "selectors": [ 14 | "[href=\"https://www.novinky.cz/stalo-se\"]:nth-child(2)" 15 | ] 16 | }, 17 | "what": [ 18 | { 19 | "action": "click", 20 | "args": [ 21 | "[href=\"https://www.novinky.cz/stalo-se\"]:nth-child(2)" 22 | ] 23 | }, 24 | { 25 | "action": "waitForLoadState", 26 | "args": [ 27 | "networkidle" 28 | ] 29 | } 30 | ] 31 | }, 32 | { 33 | "where": { 34 | "url": "https://www.seznam.cz/", 35 | "selectors": [ 36 | "[href=\"https://www.novinky.cz/\"]:nth-child(2)" 37 | ] 38 | }, 39 | "what": [ 40 | { 41 | "action": "click", 42 | "args": [ 43 | "[href=\"https://www.novinky.cz/\"]:nth-child(2)" 44 | ] 45 | }, 46 | { 47 | "action": "waitForLoadState", 48 | "args": [ 49 | "networkidle" 50 | ] 51 | } 52 | ] 53 | }, 54 | { 55 | "where": { 56 | "url": "https://www.seznam.cz/", 57 | "selectors": [] 58 | }, 59 | "what": [ 60 | { 61 | "action": "mouse.click", 62 | "args": [ 63 | 680, 64 | 650 65 | ] 66 | }, 67 | { 68 | "action": "waitForLoadState", 69 | "args": [ 70 | "networkidle" 71 | ] 72 | } 73 | ] 74 | }, 75 | { 76 | "where": { 77 | "url": "about:blank" 78 | }, 79 | "what": [ 80 | { 81 | "action": "goto", 82 | "args": [ 83 | "https://seznam.cz" 84 | ] 85 | }, 86 | { 87 | "action": "waitForLoadState", 88 | "args": [ 89 | "networkidle" 90 | ] 91 | } 92 | ] 93 | } 94 | ] 95 | } 96 | } -------------------------------------------------------------------------------- /examples/runs/cockatiel_a684b626-e458-4f65-9e85-124b4efab7f3.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "PASSED", 3 | "name": "cockatiel", 4 | "startedAt": "7/15/2022, 4:27:49 AM", 5 | "finishedAt": "7/15/2022, 4:28:01 AM", 6 | "duration": "12 s", 7 | "task": "task", 8 | "browserId": null, 9 | "interpreterSettings": { 10 | "maxConcurrency": 1, 11 | "maxRepeats": 1, 12 | "debug": true 13 | }, 14 | "log": "[7/15/2022, 4:27:50 AM] Current state is: \n{\n \"url\": \"about:blank\",\n \"cookies\": {},\n \"selectors\": []\n}\n[7/15/2022, 4:27:50 AM] Matched {\"url\":\"about:blank\"}\n[7/15/2022, 4:27:50 AM] Launching goto\n[7/15/2022, 4:27:51 AM] Launching waitForLoadState\n[7/15/2022, 4:27:52 AM] Current state is: \n{\n \"url\": \"https://www.google.com/\",\n \"cookies\": {\n \"CONSENT\": \"PENDING+127\",\n \"AEC\": \"AakniGPpeYzBDco9HXGC4shNPKHhTCjUVF-Eo6UKuolWZMdGXkKtMgaOBQ\",\n \"__Secure-ENID\": \"6.SE=RmSLuMEksNJ2jw8AGnleJce5a4RKmpIO9OxxIjdNm8tVJVy0HV_IfP48mWSU996Zf-O9nGC036rp_lsRJUXfD0PwhcohwvkyHDIwECwVpvYSws3xPCnEsK8A22xa2imxIYVCJAFcn2IO48__frX2R18WbdCv0Qp0_LFT218gMEs\"\n },\n \"selectors\": [\n \"#L2AGLb > .QS5gu\"\n ]\n}\n[7/15/2022, 4:27:52 AM] Matched {\"url\":\"https://www.google.com/\",\"selectors\":[\"#L2AGLb > .QS5gu\"]}\n[7/15/2022, 4:27:52 AM] Launching click\n[7/15/2022, 4:27:53 AM] Launching waitForLoadState\n[7/15/2022, 4:27:54 AM] Current state is: \n{\n \"url\": \"https://www.google.com/\",\n \"cookies\": {\n \"CONSENT\": \"PENDING+127\",\n \"AEC\": \"AakniGPpeYzBDco9HXGC4shNPKHhTCjUVF-Eo6UKuolWZMdGXkKtMgaOBQ\",\n \"SOCS\": \"CAISHAgBEhJnd3NfMjAyMjA3MDYtMF9SQzEaAmVuIAEaBgiA3MKWBg\",\n \"NID\": \"511=O-1gICXNgCgoQ2lYcup4IG4bU-80pIu8tbkKxuNzqHClQzAHPUo7E5hfTbBLjKQiLhkmCMQR_mRkRsftbBFvOXrqfs1TuBfqwGb9IpdxcDdHPfvYO-gD5iYf9VCQB72qDE04arFH9HcsZcXcYNwzn2rjqWe5pkEpnypzdE-I98c\"\n },\n \"selectors\": [\n \"[name=\\\"q\\\"]\"\n ]\n}\n[7/15/2022, 4:27:54 AM] Matched {\"url\":\"https://www.google.com/\",\"selectors\":[\"[name=\\\"q\\\"]\"]}\n[7/15/2022, 4:27:54 AM] Launching click\n[7/15/2022, 4:27:54 AM] Launching waitForLoadState\n[7/15/2022, 4:27:55 AM] Launching fill\n[7/15/2022, 4:27:56 AM] Launching waitForLoadState\n[7/15/2022, 4:27:56 AM] Launching click\n[7/15/2022, 4:27:58 AM] Current state is: \n{\n \"url\": \"https://www.google.com/search?q=cockatiel&source=hp&ei=p9DQYvFimf6TBfvrk6gO&iflsig=AJiK0e8AAAAAYtDet0FxRPEi6Gj3RS2wYA6RIO-pJ1lY&gs_ssp=eJzj4tTP1TcwLClJqjJg9OJMzk_OTizJTM0BAE0yBzc&oq=cockatiel&gs_lcp=Cgdnd3Mtd2l6EAEYADIFCC4QgAQyCAgAEIAEEMkDMgUIABCABDIFCAAQgAQyBQgAEIAEMgUIABCABDIFCAAQgAQyBQgAEIAEMgUIABCABDIFCAAQgAQ6DggAEI8BEOoCEIwDEOUCUIIIWIIIYKIQaAFwAHgAgAFmiAFmkgEDMC4xmAEAoAEBsAEB&sclient=gws-wiz\",\n \"cookies\": {\n \"CONSENT\": \"PENDING+127\",\n \"AEC\": \"AakniGPpeYzBDco9HXGC4shNPKHhTCjUVF-Eo6UKuolWZMdGXkKtMgaOBQ\",\n \"SOCS\": \"CAISHAgBEhJnd3NfMjAyMjA3MDYtMF9SQzEaAmVuIAEaBgiA3MKWBg\",\n \"NID\": \"511=OP6iHs1mZCELUooYU666JD_tGwq_TW8ruOmGJE8GzOYuecVOmyFhmVR175qu_d4DrMKXuLyFXou9GxRwuZRvJq8BWlwkgOICCo6UsiW9se2qPdh1TtJIH51ZrUDgjKHx70MryNWtNfWTs1L1rBjsKqLIchVb7g6GVgdeu5-WTns\",\n \"DV\": \"I9_rYO3UeYMqoLzqnEwKQNDBSPL6H1igFLPp11GAXgAAAAA\"\n },\n \"selectors\": [\n \".hdtb-mitem:nth-child(2) > a svg\"\n ]\n}\n[7/15/2022, 4:27:58 AM] Matched {\"url\":{},\"selectors\":[\".hdtb-mitem:nth-child(2) > a svg\"]}\n[7/15/2022, 4:27:58 AM] Launching click\n[7/15/2022, 4:27:59 AM] Launching waitForLoadState\n[7/15/2022, 4:28:01 AM] Current state is: \n{\n \"url\": \"https://www.google.com/search?q=cockatiel&source=lnms&tbm=isch&sa=X&ved=2ahUKEwjorPmg7Pn4AhVZuKQKHRPqDyUQ_AUoAXoECAIQAw&biw=1280&bih=720&dpr=1\",\n \"cookies\": {\n \"CONSENT\": \"PENDING+127\",\n \"AEC\": \"AakniGPpeYzBDco9HXGC4shNPKHhTCjUVF-Eo6UKuolWZMdGXkKtMgaOBQ\",\n \"SOCS\": \"CAISHAgBEhJnd3NfMjAyMjA3MDYtMF9SQzEaAmVuIAEaBgiA3MKWBg\",\n \"NID\": \"511=OP6iHs1mZCELUooYU666JD_tGwq_TW8ruOmGJE8GzOYuecVOmyFhmVR175qu_d4DrMKXuLyFXou9GxRwuZRvJq8BWlwkgOICCo6UsiW9se2qPdh1TtJIH51ZrUDgjKHx70MryNWtNfWTs1L1rBjsKqLIchVb7g6GVgdeu5-WTns\",\n \"DV\": \"I9_rYO3UeYMqoLzqnEwKQNDBSPL6H1igFLPp11GAXgAAAAA\",\n \"1P_JAR\": \"2022-7-15-2\",\n \"OTZ\": \"6592468_48_52_123900_48_436380\"\n },\n \"selectors\": [\n \"[name=\\\"q\\\"]\"\n ]\n}\n[7/15/2022, 4:28:01 AM] Matched undefined", 15 | "runId": "a684b626-e458-4f65-9e85-124b4efab7f3", 16 | "serializableOutput": {}, 17 | "binaryOutput": {} 18 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-recorder", 3 | "version": "0.1.0", 4 | "author": "Marketa Sauerova", 5 | "license": "ISC", 6 | "dependencies": { 7 | "@emotion/react": "^11.9.0", 8 | "@emotion/styled": "^11.8.1", 9 | "@mui/icons-material": "^5.5.1", 10 | "@mui/lab": "^5.0.0-alpha.80", 11 | "@mui/material": "^5.6.2", 12 | "@testing-library/jest-dom": "^5.16.4", 13 | "@testing-library/react": "^13.1.1", 14 | "@testing-library/user-event": "^13.5.0", 15 | "@types/jest": "^27.4.1", 16 | "@types/node": "^16.11.27", 17 | "@types/react": "^18.0.5", 18 | "@types/react-dom": "^18.0.1", 19 | "@types/uuid": "^8.3.4", 20 | "@wbr-project/wbr-interpret": "^0.9.3-marketa.1", 21 | "axios": "^0.26.0", 22 | "buffer": "^6.0.3", 23 | "cors": "^2.8.5", 24 | "dotenv": "^16.0.0", 25 | "express": "^4.17.2", 26 | "fortawesome": "^0.0.1-security", 27 | "loglevel": "^1.8.0", 28 | "loglevel-plugin-remote": "^0.6.8", 29 | "playwright": "^1.18.1", 30 | "prismjs": "^1.28.0", 31 | "react": "^18.0.0", 32 | "react-dom": "^18.0.0", 33 | "react-highlight": "^0.14.0", 34 | "react-scripts": "5.0.1", 35 | "react-simple-code-editor": "^0.11.2", 36 | "react-transition-group": "^4.4.2", 37 | "socket.io": "^4.4.1", 38 | "socket.io-client": "^4.4.1", 39 | "styled-components": "^5.3.3", 40 | "typedoc": "^0.23.8", 41 | "typescript": "^4.6.3", 42 | "uuid": "^8.3.2", 43 | "uuidv4": "^6.2.12", 44 | "web-vitals": "^2.1.4", 45 | "winston": "^3.5.1" 46 | }, 47 | "scripts": { 48 | "start": "concurrently -k \"npm run server\" \"npm run client\"", 49 | "server": "./node_modules/.bin/nodemon server/src/server.ts", 50 | "client": "react-app-rewired start", 51 | "build": "react-app-rewired build", 52 | "test": "react-app-rewired test", 53 | "eject": "react-scripts eject", 54 | "lint": "./node_modules/.bin/eslint ." 55 | }, 56 | "eslintConfig": { 57 | "extends": [ 58 | "react-app", 59 | "react-app/jest" 60 | ] 61 | }, 62 | "browserslist": { 63 | "production": [ 64 | ">0.2%", 65 | "not dead", 66 | "not op_mini all" 67 | ], 68 | "development": [ 69 | "last 1 chrome version", 70 | "last 1 firefox version", 71 | "last 1 safari version" 72 | ] 73 | }, 74 | "devDependencies": { 75 | "@types/express": "^4.17.13", 76 | "@types/loglevel": "^1.6.3", 77 | "@types/node": "^17.0.15", 78 | "@types/prismjs": "^1.26.0", 79 | "@types/react-highlight": "^0.12.5", 80 | "@types/react-transition-group": "^4.4.4", 81 | "@types/styled-components": "^5.1.23", 82 | "concurrently": "^7.0.0", 83 | "nodemon": "^2.0.15", 84 | "react-app-rewired": "^2.2.1", 85 | "ts-node": "^10.4.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /public/img/recording-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sauermar/web-browser-recorder/0590be482fb9686acb7a070f2d9b2b5fb26bb12f/public/img/recording-main.png -------------------------------------------------------------------------------- /public/img/recordings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sauermar/web-browser-recorder/0590be482fb9686acb7a070f2d9b2b5fb26bb12f/public/img/recordings.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 16 | 17 | 26 | Browser Recorder 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /server/src/browser-management/classes/BrowserPool.ts: -------------------------------------------------------------------------------- 1 | import { RemoteBrowser } from "./RemoteBrowser"; 2 | import logger from "../../logger"; 3 | 4 | /** 5 | * @category Types 6 | */ 7 | interface BrowserPoolInfo { 8 | /** 9 | * The instance of remote browser. 10 | */ 11 | browser: RemoteBrowser, 12 | /** 13 | * States if the browser's instance is being actively used. 14 | * Helps to persist the progress on the frontend when the application has been reloaded. 15 | * @default false 16 | */ 17 | active: boolean, 18 | } 19 | 20 | /** 21 | * Dictionary of all the active remote browser's instances indexed by their id. 22 | * The value in this dictionary is of type BrowserPoolInfo, 23 | * which provides additional information about the browser's usage. 24 | * @category Types 25 | */ 26 | interface PoolDictionary { 27 | [key: string]: BrowserPoolInfo, 28 | } 29 | 30 | /** 31 | * A browser pool is a collection of remote browsers that are initialized and ready to be used. 32 | * Adds the possibility to add, remove and retrieve remote browsers from the pool. 33 | * It is possible to manage multiple browsers for creating or running a recording. 34 | * @category BrowserManagement 35 | */ 36 | export class BrowserPool { 37 | 38 | /** 39 | * Holds all the instances of remote browsers. 40 | */ 41 | private pool : PoolDictionary = {}; 42 | 43 | /** 44 | * Adds a remote browser instance to the pool indexed by the id. 45 | * @param id remote browser instance's id 46 | * @param browser remote browser instance 47 | * @param active states if the browser's instance is being actively used 48 | */ 49 | public addRemoteBrowser = (id: string, browser: RemoteBrowser, active: boolean = false): void => { 50 | this.pool = { 51 | ...this.pool, 52 | [id]: { 53 | browser, 54 | active, 55 | }, 56 | } 57 | logger.log('debug', `Remote browser with id: ${id} added to the pool`); 58 | }; 59 | 60 | /** 61 | * Removes the remote browser instance from the pool. 62 | * @param id remote browser instance's id 63 | * @returns true if the browser was removed successfully, false otherwise 64 | */ 65 | public deleteRemoteBrowser = (id: string) : boolean => { 66 | if (!this.pool[id]) { 67 | logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`); 68 | return false; 69 | } 70 | delete(this.pool[id]); 71 | logger.log('debug', `Remote browser with id: ${id} deleted from the pool`); 72 | return true; 73 | }; 74 | 75 | /** 76 | * Returns the remote browser instance from the pool. 77 | * @param id remote browser instance's id 78 | * @returns remote browser instance or undefined if it does not exist in the pool 79 | */ 80 | public getRemoteBrowser = (id: string) : RemoteBrowser | undefined => { 81 | logger.log('debug', `Remote browser with id: ${id} retrieved from the pool`); 82 | return this.pool[id]?.browser; 83 | }; 84 | 85 | /** 86 | * Returns the active browser's instance id from the pool. 87 | * If there is no active browser, it returns undefined. 88 | * If there are multiple active browsers, it returns the first one. 89 | * @returns the first remote active browser instance's id from the pool 90 | */ 91 | public getActiveBrowserId = () : string | null => { 92 | for (const id of Object.keys(this.pool)) { 93 | if (this.pool[id].active) { 94 | return id; 95 | } 96 | } 97 | logger.log('warn', `No active browser in the pool`); 98 | return null; 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /server/src/constants/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * It is possible to use a local dotenv file to configure the server. 3 | */ 4 | export const SERVER_PORT = process.env.SERVER_PORT ? Number(process.env.SERVER_PORT) : 8080 5 | export const DEBUG = process.env.DEBUG === 'true' 6 | export const LOGS_PATH = process.env.LOGS_PATH ?? 'server/logs' 7 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./server"; 2 | export * from "./logger"; 3 | export * from "./types"; 4 | export * from "./browser-management/controller"; 5 | export * from "./browser-management/inputHandlers"; 6 | export * from "./browser-management/classes/RemoteBrowser"; 7 | export * from "./browser-management/classes/BrowserPool"; 8 | export * from "./socket-connection/connection"; 9 | export * from "./workflow-management/selector"; 10 | export * from "./workflow-management/storage"; 11 | export * from "./workflow-management/utils"; 12 | export * from "./workflow-management/classes/Interpreter"; 13 | export * from "./workflow-management/classes/Generator"; 14 | -------------------------------------------------------------------------------- /server/src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Server side logger set up. Uses winston library with customized log format 3 | * and multiple logging outputs. 4 | */ 5 | import { createLogger, format, transports } from 'winston'; 6 | import { DEBUG, LOGS_PATH } from "./constants/config"; 7 | 8 | const { combine, timestamp, printf } = format; 9 | 10 | /** 11 | * Winston logger instance. 12 | * Used for logging in the server side. 13 | * Logs are being stored inside the logs' directory. 14 | */ 15 | const logger = createLogger({ 16 | format: combine( 17 | timestamp(), 18 | printf(info => `${info.timestamp} ${info.level}: ${info.message}`), 19 | ), 20 | defaultMeta: { service: 'user-service' }, 21 | transports: [ 22 | new transports.Console({ level: DEBUG ? 'info' : 'debug' }), 23 | new transports.File({ filename: `${LOGS_PATH}/error.log`, level: 'error' }), 24 | new transports.File({ filename: `${LOGS_PATH}/combined.log`, level: 'debug' }), 25 | ], 26 | }); 27 | 28 | export default logger; 29 | -------------------------------------------------------------------------------- /server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { router as record} from './record'; 2 | import { router as workflow} from './workflow'; 3 | import { router as storage} from './storage'; 4 | 5 | export { 6 | record, 7 | workflow, 8 | storage, 9 | }; 10 | -------------------------------------------------------------------------------- /server/src/routes/record.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * RESTful API endpoints handling remote browser recording sessions. 3 | */ 4 | import { Router } from 'express'; 5 | 6 | import { 7 | initializeRemoteBrowserForRecording, 8 | destroyRemoteBrowser, 9 | getActiveBrowserId, 10 | interpretWholeWorkflow, 11 | stopRunningInterpretation, 12 | getRemoteBrowserCurrentUrl, getRemoteBrowserCurrentTabs, 13 | } from '../browser-management/controller' 14 | import { chromium } from "playwright"; 15 | import logger from "../logger"; 16 | 17 | export const router = Router(); 18 | 19 | /** 20 | * Logs information about remote browser recording session. 21 | */ 22 | router.all('/', (req, res, next) => { 23 | logger.log('debug',`The record API was invoked: ${req.url}`) 24 | next() // pass control to the next handler 25 | }) 26 | 27 | /** 28 | * GET endpoint for starting the remote browser recording session. 29 | * returns session's id 30 | */ 31 | router.get('/start', (req, res) => { 32 | const id = initializeRemoteBrowserForRecording({ 33 | browser: chromium, 34 | launchOptions: { 35 | headless: true, 36 | } 37 | }); 38 | return res.send(id); 39 | }); 40 | 41 | /** 42 | * POST endpoint for starting the remote browser recording session accepting browser launch options. 43 | * returns session's id 44 | */ 45 | router.post('/start', (req, res) => { 46 | const id = initializeRemoteBrowserForRecording({ 47 | browser: chromium, 48 | launchOptions: req.body, 49 | }); 50 | return res.send(id); 51 | }); 52 | 53 | /** 54 | * GET endpoint for terminating the remote browser recording session. 55 | * returns whether the termination was successful 56 | */ 57 | router.get('/stop/:browserId', async (req, res) => { 58 | const success = await destroyRemoteBrowser(req.params.browserId); 59 | return res.send(success); 60 | }); 61 | 62 | /** 63 | * GET endpoint for getting the id of the active remote browser. 64 | */ 65 | router.get('/active', (req, res) => { 66 | const id = getActiveBrowserId(); 67 | return res.send(id); 68 | }); 69 | 70 | /** 71 | * GET endpoint for getting the current url of the active remote browser. 72 | */ 73 | router.get('/active/url', (req, res) => { 74 | const id = getActiveBrowserId(); 75 | if (id) { 76 | const url = getRemoteBrowserCurrentUrl(id); 77 | return res.send(url); 78 | } 79 | return res.send(null); 80 | }); 81 | 82 | /** 83 | * GET endpoint for getting the current tabs of the active remote browser. 84 | */ 85 | router.get('/active/tabs', (req, res) => { 86 | const id = getActiveBrowserId(); 87 | if (id) { 88 | const hosts = getRemoteBrowserCurrentTabs(id); 89 | return res.send(hosts); 90 | } 91 | return res.send([]); 92 | }); 93 | 94 | /** 95 | * GET endpoint for starting an interpretation of the currently generated workflow. 96 | */ 97 | router.get('/interpret', async (req, res) => { 98 | try { 99 | await interpretWholeWorkflow(); 100 | return res.send('interpretation done'); 101 | } catch (e) { 102 | return res.send('interpretation done'); 103 | return res.status(400); 104 | } 105 | }); 106 | 107 | /** 108 | * GET endpoint for stopping an ongoing interpretation of the currently generated workflow. 109 | */ 110 | router.get('/interpret/stop', async (req, res) => { 111 | await stopRunningInterpretation(); 112 | return res.send('interpretation stopped'); 113 | }); 114 | -------------------------------------------------------------------------------- /server/src/routes/workflow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * RESTful API endpoints handling currently generated workflow management. 3 | */ 4 | 5 | import { Router } from 'express'; 6 | import logger from "../logger"; 7 | import { browserPool } from "../server"; 8 | import { readFile } from "../workflow-management/storage"; 9 | 10 | export const router = Router(); 11 | 12 | /** 13 | * Logs information about workflow API. 14 | */ 15 | router.all('/', (req, res, next) => { 16 | logger.log('debug',`The workflow API was invoked: ${req.url}`) 17 | next() // pass control to the next handler 18 | }) 19 | 20 | /** 21 | * GET endpoint for a recording linked to a remote browser instance. 22 | * returns session's id 23 | */ 24 | router.get('/:browserId', (req, res) => { 25 | const activeBrowser = browserPool.getRemoteBrowser(req.params.browserId); 26 | let workflowFile = null; 27 | if (activeBrowser && activeBrowser.generator) { 28 | workflowFile = activeBrowser.generator.getWorkflowFile(); 29 | } 30 | return res.send(workflowFile); 31 | }); 32 | 33 | /** 34 | * Get endpoint returning the parameter array of the recording associated with the browserId browser instance. 35 | */ 36 | router.get('/params/:browserId', (req, res) => { 37 | const activeBrowser = browserPool.getRemoteBrowser(req.params.browserId); 38 | let params = null; 39 | if (activeBrowser && activeBrowser.generator) { 40 | params = activeBrowser.generator.getParams(); 41 | } 42 | return res.send(params); 43 | }); 44 | 45 | /** 46 | * DELETE endpoint for deleting a pair from the generated workflow. 47 | */ 48 | router.delete('/pair/:index', (req, res) => { 49 | const id = browserPool.getActiveBrowserId(); 50 | if (id) { 51 | const browser = browserPool.getRemoteBrowser(id); 52 | if (browser) { 53 | browser.generator?.removePairFromWorkflow(parseInt(req.params.index)); 54 | const workflowFile = browser.generator?.getWorkflowFile(); 55 | return res.send(workflowFile); 56 | } 57 | } 58 | return res.send(null); 59 | }); 60 | 61 | /** 62 | * POST endpoint for adding a pair to the generated workflow. 63 | */ 64 | router.post('/pair/:index', (req, res) => { 65 | const id = browserPool.getActiveBrowserId(); 66 | if (id) { 67 | const browser = browserPool.getRemoteBrowser(id); 68 | logger.log('debug', `Adding pair to workflow`); 69 | if (browser) { 70 | logger.log('debug', `Adding pair to workflow: ${JSON.stringify(req.body)}`); 71 | if (req.body.pair) { 72 | browser.generator?.addPairToWorkflow(parseInt(req.params.index), req.body.pair); 73 | const workflowFile = browser.generator?.getWorkflowFile(); 74 | return res.send(workflowFile); 75 | } 76 | } 77 | } 78 | return res.send(null); 79 | }); 80 | 81 | /** 82 | * PUT endpoint for updating a pair in the generated workflow. 83 | */ 84 | router.put('/pair/:index', (req, res) => { 85 | const id = browserPool.getActiveBrowserId(); 86 | if (id) { 87 | const browser = browserPool.getRemoteBrowser(id); 88 | logger.log('debug', `Updating pair in workflow`); 89 | if (browser) { 90 | logger.log('debug', `New value: ${JSON.stringify(req.body)}`); 91 | if (req.body.pair) { 92 | browser.generator?.updatePairInWorkflow(parseInt(req.params.index), req.body.pair); 93 | const workflowFile = browser.generator?.getWorkflowFile(); 94 | return res.send(workflowFile); 95 | } 96 | } 97 | } 98 | return res.send(null); 99 | }); 100 | 101 | /** 102 | * PUT endpoint for updating the currently generated workflow file from the one in the storage. 103 | */ 104 | router.put('/:browserId/:fileName', async (req, res) => { 105 | try { 106 | const browser = browserPool.getRemoteBrowser(req.params.browserId); 107 | logger.log('debug', `Updating workflow file`); 108 | if (browser && browser.generator) { 109 | const recording = await readFile(`./../storage/recordings/${req.params.fileName}.waw.json`) 110 | const parsedRecording = JSON.parse(recording); 111 | if (parsedRecording.recording) { 112 | browser.generator?.updateWorkflowFile(parsedRecording.recording, parsedRecording.recording_meta); 113 | const workflowFile = browser.generator?.getWorkflowFile(); 114 | return res.send(workflowFile); 115 | } 116 | } 117 | return res.send(null); 118 | } catch (e) { 119 | const {message} = e as Error; 120 | logger.log('info', `Error while reading a recording with name: ${req.params.fileName}.waw.json`); 121 | return res.send(null); 122 | } 123 | }); 124 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP express server for the browser recorder client application. 3 | */ 4 | import express from 'express'; 5 | import http from 'http'; 6 | import cors from 'cors'; 7 | /** 8 | * loads .env config to the process - allows a custom configuration for the server 9 | */ 10 | import 'dotenv/config'; 11 | 12 | import { record, workflow, storage } from './routes'; 13 | import { BrowserPool } from "./browser-management/classes/BrowserPool"; 14 | import logger from './logger' 15 | import { SERVER_PORT } from "./constants/config"; 16 | import {Server} from "socket.io"; 17 | 18 | /** 19 | * Creates a new express server instance. 20 | * @type {express.Express} 21 | */ 22 | const app = express(); 23 | /** 24 | * Enabling cors for communication with client on a different port/domain. 25 | */ 26 | app.use(cors()); 27 | /** 28 | * Automatic parsing of incoming JSON data. 29 | */ 30 | app.use(express.json()) 31 | 32 | /** 33 | * Initialize the server. 34 | * @type {http.Server} 35 | */ 36 | const server = http.createServer(app); 37 | 38 | /** 39 | * Globally exported singleton instance of socket.io for socket communication with the client. 40 | * @type {Server} 41 | */ 42 | export const io = new Server(server); 43 | /** 44 | * {@link BrowserPool} globally exported singleton instance for managing browsers. 45 | */ 46 | export const browserPool = new BrowserPool(); 47 | 48 | /** 49 | * API routes for the server. 50 | */ 51 | app.use('/record', record); 52 | app.use('/workflow', workflow); 53 | app.use('/storage', storage); 54 | 55 | app.get('/', function (req, res) { 56 | return res.send('Welcome to the BR recorder server :-)'); 57 | }); 58 | 59 | server.listen(SERVER_PORT, () => logger.log('info',`Server listening on port ${SERVER_PORT}`)); 60 | -------------------------------------------------------------------------------- /server/src/socket-connection/connection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handles creation of Socket.io connections on the side of the server. 3 | */ 4 | import {Namespace, Socket} from 'socket.io'; 5 | import logger from "../logger"; 6 | import registerInputHandlers from '../browser-management/inputHandlers' 7 | 8 | /** 9 | * Opens a websocket canal for duplex data transfer and registers all handlers for this data for the recording session. 10 | * Uses socket.io dynamic namespaces for multiplexing the traffic from different running remote browser instances. 11 | * @param io dynamic namespace on the socket.io server 12 | * @param callback function called after the connection is created providing the socket resource 13 | * @category BrowserManagement 14 | */ 15 | export const createSocketConnection = ( 16 | io: Namespace, 17 | callback: (socket: Socket) => void, 18 | ) => { 19 | const onConnection = async (socket: Socket) => { 20 | logger.log('info',"Client connected " + socket.id); 21 | registerInputHandlers(socket); 22 | socket.on('disconnect', () => logger.log('info', "Client disconnected " + socket.id)); 23 | callback(socket); 24 | } 25 | 26 | io.on('connection', onConnection); 27 | }; 28 | 29 | /** 30 | * Opens a websocket canal for duplex data transfer for the recording run. 31 | * Uses socket.io dynamic namespaces for multiplexing the traffic from different running remote browser instances. 32 | * @param io dynamic namespace on the socket.io server 33 | * @param callback function called after the connection is created providing the socket resource 34 | * @category BrowserManagement 35 | */ 36 | export const createSocketConnectionForRun = ( 37 | io: Namespace, 38 | callback: (socket: Socket) => void, 39 | ) => { 40 | const onConnection = async (socket: Socket) => { 41 | logger.log('info',"Client connected " + socket.id); 42 | socket.on('disconnect', () => logger.log('info', "Client disconnected " + socket.id)); 43 | callback(socket); 44 | } 45 | 46 | io.on('connection', onConnection); 47 | }; 48 | -------------------------------------------------------------------------------- /server/src/workflow-management/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A group of functions for storing recordings on the file system. 3 | * Functions are asynchronous to unload the server from heavy file system operations. 4 | */ 5 | import fs from 'fs'; 6 | import * as path from "path"; 7 | 8 | /** 9 | * Reads a file from path and returns its content as a string. 10 | * @param path The path to the file. 11 | * @returns {Promise} 12 | * @category WorkflowManagement-Storage 13 | */ 14 | export const readFile = (path: string): Promise => { 15 | return new Promise((resolve, reject) => { 16 | fs.readFile(path, 'utf8', (err, data) => { 17 | if (err) { 18 | reject(err); 19 | } else { 20 | resolve(data); 21 | } 22 | }); 23 | }); 24 | }; 25 | 26 | /** 27 | * Writes a string to a file. If the file already exists, it is overwritten. 28 | * @param path The path to the file. 29 | * @param data The data to write to the file. 30 | * @returns {Promise} 31 | * @category WorkflowManagement-Storage 32 | */ 33 | export const saveFile = (path: string, data: string): Promise => { 34 | return new Promise((resolve, reject) => { 35 | fs.writeFile(path, data, (err) => { 36 | if (err) { 37 | reject(err); 38 | } else { 39 | resolve(); 40 | } 41 | }); 42 | }); 43 | }; 44 | 45 | /** 46 | * Deletes a file from the file system. 47 | * @param path The path to the file. 48 | * @returns {Promise} 49 | * @category WorkflowManagement-Storage 50 | */ 51 | export const deleteFile = (path: string): Promise => { 52 | return new Promise((resolve, reject) => { 53 | fs.unlink(path, (err) => { 54 | if (err) { 55 | reject(err); 56 | } else { 57 | resolve(); 58 | } 59 | }); 60 | }); 61 | }; 62 | 63 | /** 64 | * A helper function to apply a callback to the all resolved 65 | * promises made out of an array of the items. 66 | * @param items An array of items. 67 | * @param block The function to call for each item after the promise for it was resolved. 68 | * @returns {Promise} 69 | * @category WorkflowManagement-Storage 70 | */ 71 | function promiseAllP(items: any, block: any) { 72 | let promises: any = []; 73 | items.forEach(function(item : any, index: number) { 74 | promises.push( function(item,i) { 75 | return new Promise(function(resolve, reject) { 76 | // @ts-ignore 77 | return block.apply(this,[item,index,resolve,reject]); 78 | }); 79 | }(item,index)) 80 | }); 81 | return Promise.all(promises); 82 | } 83 | 84 | /** 85 | * Reads all files from a directory and returns an array of their contents. 86 | * @param dirname The path to the directory. 87 | * @category WorkflowManagement-Storage 88 | * @returns {Promise} 89 | */ 90 | export const readFiles = (dirname: string): Promise => { 91 | return new Promise((resolve, reject) => { 92 | fs.readdir(dirname, function(err, filenames) { 93 | if (err) return reject(err); 94 | promiseAllP(filenames.filter((filename: string) => !filename.startsWith('.')), 95 | (filename: string, index : number, resolve: any, reject: any) => { 96 | fs.readFile(path.resolve(dirname, filename), 'utf-8', function(err, content) { 97 | if (err) return reject(err); 98 | return resolve(content); 99 | }); 100 | }) 101 | .then(results => { 102 | return resolve(results); 103 | }) 104 | .catch(error => { 105 | return reject(error); 106 | }); 107 | }); 108 | }); 109 | } 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /server/src/workflow-management/utils.ts: -------------------------------------------------------------------------------- 1 | import { Action, ActionType, TagName } from "../types"; 2 | 3 | /** 4 | * A helper function to get the best selector for the specific user action. 5 | * @param action The user action. 6 | * @returns {string|null} 7 | * @category WorkflowManagement-Selectors 8 | */ 9 | export const getBestSelectorForAction = (action: Action) => { 10 | switch (action.type) { 11 | case ActionType.Click: 12 | case ActionType.Hover: 13 | case ActionType.DragAndDrop: { 14 | const selectors = action.selectors; 15 | // less than 25 characters, and element only has text inside 16 | const textSelector = 17 | selectors?.text?.length != null && 18 | selectors?.text?.length < 25 && 19 | action.hasOnlyText 20 | ? `text=${selectors.text}` 21 | : null; 22 | 23 | if (action.tagName === TagName.Input) { 24 | return ( 25 | selectors.testIdSelector ?? 26 | selectors?.id ?? 27 | selectors?.formSelector ?? 28 | selectors?.accessibilitySelector ?? 29 | selectors?.generalSelector ?? 30 | selectors?.attrSelector ?? 31 | null 32 | ); 33 | } 34 | if (action.tagName === TagName.A) { 35 | return ( 36 | selectors.testIdSelector ?? 37 | selectors?.id ?? 38 | selectors?.hrefSelector ?? 39 | selectors?.accessibilitySelector ?? 40 | selectors?.generalSelector ?? 41 | selectors?.attrSelector ?? 42 | null 43 | ); 44 | } 45 | 46 | // Prefer text selectors for spans, ems over general selectors 47 | if ( 48 | action.tagName === TagName.Span || 49 | action.tagName === TagName.EM || 50 | action.tagName === TagName.Cite || 51 | action.tagName === TagName.B || 52 | action.tagName === TagName.Strong 53 | ) { 54 | return ( 55 | selectors.testIdSelector ?? 56 | selectors?.id ?? 57 | selectors?.accessibilitySelector ?? 58 | selectors?.hrefSelector ?? 59 | textSelector ?? 60 | selectors?.generalSelector ?? 61 | selectors?.attrSelector ?? 62 | null 63 | ); 64 | } 65 | return ( 66 | selectors.testIdSelector ?? 67 | selectors?.id ?? 68 | selectors?.accessibilitySelector ?? 69 | selectors?.hrefSelector ?? 70 | selectors?.generalSelector ?? 71 | selectors?.attrSelector ?? 72 | null 73 | ); 74 | } 75 | case ActionType.Input: 76 | case ActionType.Keydown: { 77 | const selectors = action.selectors; 78 | return ( 79 | selectors.testIdSelector ?? 80 | selectors?.id ?? 81 | selectors?.formSelector ?? 82 | selectors?.accessibilitySelector ?? 83 | selectors?.generalSelector ?? 84 | selectors?.attrSelector ?? 85 | null 86 | ); 87 | } 88 | default: 89 | break; 90 | } 91 | return null; 92 | } 93 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { GlobalInfoProvider } from "./context/globalInfo"; 4 | import { PageWrapper } from "./pages/PageWrappper"; 5 | 6 | function App () { 7 | 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /src/api/recording.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from "axios"; 2 | 3 | const axios = require('axios').default; 4 | 5 | 6 | export const startRecording = async() : Promise => { 7 | try { 8 | const response = await axios.get('http://localhost:8080/record/start') 9 | if (response.status === 200) { 10 | return response.data; 11 | } else { 12 | throw new Error('Couldn\'t start recording'); 13 | } 14 | } catch(error: any) { 15 | return ''; 16 | } 17 | }; 18 | 19 | export const stopRecording = async (id: string): Promise => { 20 | await axios.get(`http://localhost:8080/record/stop/${id}`) 21 | .then((response : AxiosResponse) => { 22 | }) 23 | .catch((error: any) => { 24 | }); 25 | }; 26 | 27 | export const getActiveBrowserId = async(): Promise => { 28 | try { 29 | const response = await axios.get('http://localhost:8080/record/active'); 30 | if (response.status === 200) { 31 | return response.data; 32 | } else { 33 | throw new Error('Couldn\'t get active browser'); 34 | } 35 | } catch(error: any) { 36 | return ''; 37 | } 38 | }; 39 | 40 | export const interpretCurrentRecording = async(): Promise => { 41 | try { 42 | const response = await axios.get('http://localhost:8080/record/interpret'); 43 | if (response.status === 200) { 44 | return true; 45 | } else { 46 | throw new Error('Couldn\'t interpret current recording'); 47 | } 48 | } catch(error: any) { 49 | console.log(error); 50 | return false; 51 | } 52 | }; 53 | 54 | export const stopCurrentInterpretation = async(): Promise => { 55 | try { 56 | const response = await axios.get('http://localhost:8080/record/interpret/stop'); 57 | if (response.status === 200) { 58 | return; 59 | } else { 60 | throw new Error('Couldn\'t interpret current recording'); 61 | } 62 | } catch(error: any) { 63 | console.log(error); 64 | } 65 | }; 66 | 67 | export const getCurrentUrl = async (): Promise => { 68 | try { 69 | const response = await axios.get('http://localhost:8080/record/active/url'); 70 | if (response.status === 200) { 71 | return response.data; 72 | } else { 73 | throw new Error('Couldn\'t retrieve stored recordings'); 74 | } 75 | } catch(error: any) { 76 | console.log(error); 77 | return null; 78 | } 79 | }; 80 | 81 | export const getCurrentTabs = async (): Promise => { 82 | try { 83 | const response = await axios.get('http://localhost:8080/record/active/tabs'); 84 | if (response.status === 200) { 85 | return response.data; 86 | } else { 87 | throw new Error('Couldn\'t retrieve stored recordings'); 88 | } 89 | } catch(error: any) { 90 | console.log(error); 91 | return null; 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /src/api/storage.ts: -------------------------------------------------------------------------------- 1 | import { default as axios } from "axios"; 2 | import { WorkflowFile } from "@wbr-project/wbr-interpret"; 3 | import { RunSettings } from "../components/molecules/RunSettings"; 4 | import { CreateRunResponse } from "../pages/MainPage"; 5 | 6 | export const getStoredRecordings = async (): Promise => { 7 | try { 8 | const response = await axios.get('http://localhost:8080/storage/recordings'); 9 | if (response.status === 200) { 10 | return response.data; 11 | } else { 12 | throw new Error('Couldn\'t retrieve stored recordings'); 13 | } 14 | } catch(error: any) { 15 | console.log(error); 16 | return null; 17 | } 18 | }; 19 | 20 | export const getStoredRuns = async (): Promise => { 21 | try { 22 | const response = await axios.get('http://localhost:8080/storage/runs'); 23 | if (response.status === 200) { 24 | return response.data; 25 | } else { 26 | throw new Error('Couldn\'t retrieve stored recordings'); 27 | } 28 | } catch(error: any) { 29 | console.log(error); 30 | return null; 31 | } 32 | }; 33 | 34 | export const deleteRecordingFromStorage = async (fileName: string): Promise => { 35 | try { 36 | const response = await axios.delete(`http://localhost:8080/storage/recordings/${fileName}`); 37 | if (response.status === 200) { 38 | return response.data; 39 | } else { 40 | throw new Error(`Couldn't delete stored recording ${fileName}`); 41 | } 42 | } catch(error: any) { 43 | console.log(error); 44 | return false; 45 | } 46 | }; 47 | 48 | export const deleteRunFromStorage = async (fileName: string): Promise => { 49 | try { 50 | const response = await axios.delete(`http://localhost:8080/storage/runs/${fileName}`); 51 | if (response.status === 200) { 52 | return response.data; 53 | } else { 54 | throw new Error(`Couldn't delete stored recording ${fileName}`); 55 | } 56 | } catch(error: any) { 57 | console.log(error); 58 | return false; 59 | } 60 | }; 61 | 62 | export const editRecordingFromStorage = async (browserId: string, fileName: string): Promise => { 63 | try { 64 | const response = await axios.put(`http://localhost:8080/workflow/${browserId}/${fileName}`); 65 | if (response.status === 200) { 66 | return response.data; 67 | } else { 68 | throw new Error(`Couldn't edit stored recording ${fileName}`); 69 | } 70 | } catch(error: any) { 71 | console.log(error); 72 | return null; 73 | } 74 | }; 75 | 76 | export const createRunForStoredRecording = async (fileName: string, settings: RunSettings): Promise => { 77 | try { 78 | const response = await axios.put( 79 | `http://localhost:8080/storage/runs/${fileName}`, 80 | {...settings}); 81 | if (response.status === 200) { 82 | return response.data; 83 | } else { 84 | throw new Error(`Couldn't create a run for a recording ${fileName}`); 85 | } 86 | } catch(error: any) { 87 | console.log(error); 88 | return {browserId: '', runId: ''}; 89 | } 90 | } 91 | 92 | export const interpretStoredRecording = async (fileName: string, runId: string): Promise => { 93 | try { 94 | const response = await axios.post(`http://localhost:8080/storage/runs/run/${fileName}/${runId}`); 95 | if (response.status === 200) { 96 | return response.data; 97 | } else { 98 | throw new Error(`Couldn't run a recording ${fileName}`); 99 | } 100 | } catch(error: any) { 101 | console.log(error); 102 | return false; 103 | } 104 | } 105 | 106 | export const notifyAboutAbort = async (fileName: string, runId:string): Promise => { 107 | try { 108 | const response = await axios.post(`http://localhost:8080/storage/runs/abort/${fileName}/${runId}`); 109 | if (response.status === 200) { 110 | return response.data; 111 | } else { 112 | throw new Error(`Couldn't abort a running recording ${fileName} with id ${runId}`); 113 | } 114 | } catch(error: any) { 115 | console.log(error); 116 | return false; 117 | } 118 | } 119 | 120 | 121 | -------------------------------------------------------------------------------- /src/api/workflow.ts: -------------------------------------------------------------------------------- 1 | import { WhereWhatPair, WorkflowFile } from "@wbr-project/wbr-interpret"; 2 | import { emptyWorkflow } from "../shared/constants"; 3 | 4 | const axios = require('axios').default; 5 | 6 | export const getActiveWorkflow = async(id: string) : Promise => { 7 | try { 8 | const response = await axios.get(`http://localhost:8080/workflow/${id}`) 9 | if (response.status === 200) { 10 | return response.data; 11 | } else { 12 | throw new Error('Something went wrong when fetching a recorded workflow'); 13 | } 14 | } catch(error: any) { 15 | console.log(error); 16 | return emptyWorkflow; 17 | } 18 | }; 19 | 20 | export const getParamsOfActiveWorkflow = async(id: string) : Promise => { 21 | try { 22 | const response = await axios.get(`http://localhost:8080/workflow/params/${id}`) 23 | if (response.status === 200) { 24 | return response.data; 25 | } else { 26 | throw new Error('Something went wrong when fetching the parameters of the recorded workflow'); 27 | } 28 | } catch(error: any) { 29 | console.log(error); 30 | return null; 31 | } 32 | }; 33 | 34 | export const deletePair = async(index: number): Promise => { 35 | try { 36 | const response = await axios.delete(`http://localhost:8080/workflow/pair/${index}`); 37 | if (response.status === 200) { 38 | return response.data; 39 | } else { 40 | throw new Error('Something went wrong when fetching an updated workflow'); 41 | } 42 | } catch (error: any) { 43 | console.log(error); 44 | return emptyWorkflow; 45 | } 46 | }; 47 | 48 | export const AddPair = async(index: number, pair: WhereWhatPair): Promise => { 49 | try { 50 | const response = await axios.post(`http://localhost:8080/workflow/pair/${index}`, { 51 | pair, 52 | }, {headers: {'Content-Type': 'application/json'}}); 53 | if (response.status === 200) { 54 | return response.data; 55 | } else { 56 | throw new Error('Something went wrong when fetching an updated workflow'); 57 | } 58 | } catch (error: any) { 59 | console.log(error); 60 | return emptyWorkflow; 61 | } 62 | }; 63 | 64 | export const UpdatePair = async(index: number, pair: WhereWhatPair): Promise => { 65 | try { 66 | const response = await axios.put(`http://localhost:8080/workflow/pair/${index}`, { 67 | pair, 68 | }, {headers: {'Content-Type': 'application/json'}}); 69 | if (response.status === 200) { 70 | return response.data; 71 | } else { 72 | throw new Error('Something went wrong when fetching an updated workflow'); 73 | } 74 | } catch (error: any) { 75 | console.log(error); 76 | return emptyWorkflow; 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/atoms/AlertSnackbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Snackbar from '@mui/material/Snackbar'; 3 | import MuiAlert, { AlertProps } from '@mui/material/Alert'; 4 | import { useGlobalInfoStore } from "../../context/globalInfo"; 5 | 6 | const Alert = React.forwardRef(function Alert( 7 | props, 8 | ref, 9 | ) { 10 | return ; 11 | }); 12 | 13 | export interface AlertSnackbarProps { 14 | severity: 'error' | 'warning' | 'info' | 'success', 15 | message: string, 16 | isOpen: boolean, 17 | }; 18 | 19 | export const AlertSnackbar = ({ severity, message, isOpen }: AlertSnackbarProps) => { 20 | const [open, setOpen] = React.useState(isOpen); 21 | 22 | const { closeNotify } = useGlobalInfoStore(); 23 | 24 | const handleClose = (event?: React.SyntheticEvent | Event, reason?: string) => { 25 | if (reason === 'clickaway') { 26 | return; 27 | } 28 | 29 | closeNotify(); 30 | setOpen(false); 31 | }; 32 | 33 | return ( 34 | 35 | 36 | {message} 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/atoms/Box.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import { Typography } from "@mui/material"; 4 | 5 | interface BoxProps { 6 | width: number | string, 7 | height: number | string, 8 | background: string, 9 | radius: string, 10 | children?: JSX.Element, 11 | }; 12 | 13 | export const SimpleBox = ({width, height, background, radius, children}: BoxProps) => { 14 | return ( 15 | 23 | {children} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/atoms/DropdownMui.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormControl, InputLabel, Select } from "@mui/material"; 3 | import { SelectChangeEvent } from "@mui/material/Select/Select"; 4 | 5 | interface DropdownProps { 6 | id: string; 7 | label: string; 8 | value: string | undefined; 9 | handleSelect: (event: SelectChangeEvent) => void; 10 | children? : React.ReactNode; 11 | }; 12 | 13 | export const Dropdown = ({id, label, value, handleSelect, children}: DropdownProps) => { 14 | return ( 15 | 16 | {label} 17 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/atoms/GenericModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Modal, IconButton, Box } from '@mui/material'; 3 | import { Clear } from "@mui/icons-material"; 4 | 5 | interface ModalProps { 6 | isOpen: boolean; 7 | onClose: () => void; 8 | children?: JSX.Element; 9 | modalStyle?: React.CSSProperties; 10 | canBeClosed?: boolean; 11 | } 12 | 13 | export const GenericModal: FC = ( 14 | { isOpen, onClose, children, modalStyle , canBeClosed= true}) => { 15 | 16 | return ( 17 | {}} > 18 | 19 | {canBeClosed ? 20 | 21 | 22 | 23 | : null 24 | } 25 | {children} 26 | 27 | 28 | ); 29 | }; 30 | 31 | const defaultModalStyle = { 32 | position: 'absolute', 33 | top: '50%', 34 | left: '50%', 35 | transform: 'translate(-50%, -50%)', 36 | width: 500, 37 | bgcolor: 'background.paper', 38 | boxShadow: 24, 39 | p: 4, 40 | height:'60%', 41 | display:'block', 42 | overflow:'scroll', 43 | padding: '5px 25px 10px 25px', 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/atoms/Highlighter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from "styled-components"; 4 | import { mapRect } from "../../helpers/inputHelpers"; 5 | import canvas from "./canvas"; 6 | 7 | interface HighlighterProps { 8 | unmodifiedRect: DOMRect; 9 | displayedSelector: string; 10 | width: number; 11 | height: number; 12 | canvasRect: DOMRect; 13 | }; 14 | 15 | export const Highlighter = ({ unmodifiedRect, displayedSelector = '', width, height, canvasRect}: HighlighterProps) => { 16 | if (!unmodifiedRect) { 17 | return null; 18 | } else { 19 | const unshiftedRect = mapRect(unmodifiedRect, width, height); 20 | const rect = { 21 | bottom: unshiftedRect.bottom + canvasRect.top, 22 | top: unshiftedRect.top + canvasRect.top, 23 | left: unshiftedRect.left + canvasRect.left, 24 | right: unshiftedRect.right + canvasRect.left, 25 | x: unshiftedRect.x + canvasRect.left, 26 | y: unshiftedRect.y + canvasRect.top, 27 | width: unshiftedRect.width, 28 | height: unshiftedRect.height, 29 | } 30 | 31 | // make the highlighting rectangle stay in browser window boundaries 32 | if (rect.bottom > canvasRect.bottom) { 33 | rect.height = height - unshiftedRect.top; 34 | } 35 | 36 | if (rect.top < canvasRect.top) { 37 | rect.height = rect.height - (canvasRect.top - rect.top); 38 | rect.top = canvasRect.top; 39 | } 40 | 41 | if (rect.right > canvasRect.right) { 42 | rect.width = width - unshiftedRect.left; 43 | } 44 | 45 | if (rect.left < canvasRect.left) { 46 | rect.width = rect.width - (canvasRect.left - rect.left); 47 | rect.left = canvasRect.left; 48 | } 49 | 50 | 51 | return ( 52 |
53 | 60 | 65 | {displayedSelector} 66 | 67 |
68 | ); 69 | } 70 | } 71 | 72 | const HighlighterOutline = styled.div` 73 | box-sizing: border-box; 74 | pointer-events: none !important; 75 | position: fixed !important; 76 | background: #ff5d5b26 !important; 77 | border: 2px solid #ff5d5b !important; 78 | z-index: 2147483647 !important; 79 | border-radius: 5px; 80 | top: ${(p: HighlighterOutlineProps) => p.top}px; 81 | left: ${(p: HighlighterOutlineProps) => p.left}px; 82 | width: ${(p: HighlighterOutlineProps) => p.width}px; 83 | height: ${(p: HighlighterOutlineProps) => p.height}px; 84 | `; 85 | 86 | const HighlighterLabel = styled.div` 87 | pointer-events: none !important; 88 | position: fixed !important; 89 | background: #080a0b !important; 90 | color: white !important; 91 | padding: 8px !important; 92 | font-family: monospace !important; 93 | border-radius: 5px !important; 94 | z-index: 2147483647 !important; 95 | top: ${(p: HighlighterLabelProps) => p.top}px; 96 | left: ${(p: HighlighterLabelProps) => p.left}px; 97 | `; 98 | 99 | interface HighlighterLabelProps{ 100 | top: number; 101 | left: number; 102 | } 103 | 104 | interface HighlighterOutlineProps { 105 | top: number; 106 | left: number; 107 | width: number; 108 | height: number; 109 | } 110 | -------------------------------------------------------------------------------- /src/components/atoms/KeyValuePair.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from "react"; 2 | import { Box, TextField } from "@mui/material"; 3 | 4 | interface KeyValueFormProps { 5 | keyLabel?: string; 6 | valueLabel?: string; 7 | } 8 | 9 | export const KeyValuePair = forwardRef(({keyLabel, valueLabel}: KeyValueFormProps, ref) => { 10 | const [key, setKey] = React.useState(''); 11 | const [value, setValue] = React.useState(''); 12 | useImperativeHandle(ref, () => ({ 13 | getKeyValuePair() { 14 | return { key, value }; 15 | } 16 | })); 17 | return ( 18 | :not(style)': { m: 1, width: '100px' }, 22 | }} 23 | noValidate 24 | autoComplete="off" 25 | > 26 | ) => setKey(event.target.value)} 31 | size="small" 32 | required 33 | /> 34 | ) => { 39 | const num = Number(event.target.value); 40 | if (isNaN(num)){ 41 | setValue(event.target.value); 42 | } 43 | else { 44 | setValue(num); 45 | } 46 | }} 47 | size="small" 48 | required 49 | /> 50 | 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /src/components/atoms/Loader.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Stack } from "@mui/material"; 3 | 4 | export const Loader = () => { 5 | return ( 6 | 7 | 8 | Loading... 9 | 10 | ); 11 | } 12 | 13 | const StyledParagraph = styled.p` 14 | font-size: x-large; 15 | font-family: inherit; 16 | color: #1976d2; 17 | display: grid; 18 | justify-content: center; 19 | `; 20 | 21 | const StyledLoader = styled.div` 22 | border-radius: 50%; 23 | color: #1976d2; 24 | font-size: 11px; 25 | text-indent: -99999em; 26 | margin: 55px auto; 27 | position: relative; 28 | width: 10em; 29 | height: 10em; 30 | box-shadow: inset 0 0 0 1em; 31 | -webkit-transform: translateZ(0); 32 | -ms-transform: translateZ(0); 33 | transform: translateZ(0); 34 | &:before { 35 | position: absolute; 36 | content: ''; 37 | border-radius: 50%; 38 | width: 5.2em; 39 | height: 10.2em; 40 | background: #ffffff; 41 | border-radius: 10.2em 0 0 10.2em; 42 | top: -0.1em; 43 | left: -0.1em; 44 | -webkit-transform-origin: 5.1em 5.1em; 45 | transform-origin: 5.1em 5.1em; 46 | -webkit-animation: load2 2s infinite ease 1.5s; 47 | animation: load2 2s infinite ease 1.5s; 48 | } 49 | &:after { 50 | position: absolute; 51 | content: ''; 52 | border-radius: 50%; 53 | width: 5.2em; 54 | height: 10.2em; 55 | background: #ffffff; 56 | border-radius: 0 10.2em 10.2em 0; 57 | top: -0.1em; 58 | left: 4.9em; 59 | -webkit-transform-origin: 0.1em 5.1em; 60 | transform-origin: 0.1em 5.1em; 61 | -webkit-animation: load2 2s infinite ease; 62 | animation: load2 2s infinite ease; 63 | } 64 | @-webkit-keyframes load2 { 65 | 0% { 66 | -webkit-transform: rotate(0deg); 67 | transform: rotate(0deg); 68 | } 69 | 100% { 70 | -webkit-transform: rotate(360deg); 71 | transform: rotate(360deg); 72 | } 73 | } 74 | @keyframes load2 { 75 | 0% { 76 | -webkit-transform: rotate(0deg); 77 | transform: rotate(0deg); 78 | } 79 | 100% { 80 | -webkit-transform: rotate(360deg); 81 | transform: rotate(360deg); 82 | } 83 | } 84 | `; 85 | -------------------------------------------------------------------------------- /src/components/atoms/PairDisplayDiv.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import { WhereWhatPair } from "@wbr-project/wbr-interpret"; 4 | import styled from "styled-components"; 5 | 6 | interface PairDisplayDivProps { 7 | index: string; 8 | pair: WhereWhatPair; 9 | } 10 | 11 | export const PairDisplayDiv: FC = ({index, pair}) => { 12 | 13 | return ( 14 |
15 | 16 | {`Index: ${index}`} 17 | { pair.id ? `, Id: ${pair.id}` : ''} 18 | 19 | 20 | {"Where:"} 21 | 22 | 23 |
{JSON.stringify(pair?.where, undefined, 2)}
24 |
25 | 26 | {"What:"} 27 | 28 | 29 |
{JSON.stringify(pair?.what,undefined, 2)}
30 |
31 |
32 | ); 33 | } 34 | 35 | const DescriptionWrapper = styled.div` 36 | margin: 0; 37 | font-family: "Roboto","Helvetica","Arial",sans-serif; 38 | font-weight: 400; 39 | font-size: 1rem; 40 | line-height: 1.5; 41 | letter-spacing: 0.00938em; 42 | `; 43 | -------------------------------------------------------------------------------- /src/components/atoms/RecorderIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const RecordingIcon = () => { 4 | return ( 5 | 6 | 8 | 9 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/AddButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Add } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface AddButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | title?: string; 9 | disabled?: boolean; 10 | hoverEffect?: boolean; 11 | style?: React.CSSProperties; 12 | } 13 | 14 | export const AddButton: FC = ( 15 | { handleClick, 16 | size , 17 | title, 18 | disabled = false, 19 | hoverEffect= true, 20 | style 21 | }) => { 22 | return ( 23 | 33 | {title} 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/BreakpointButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Circle } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface BreakpointButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | changeColor?: boolean; 9 | } 10 | 11 | export const BreakpointButton = 12 | ({ handleClick, size, changeColor }: BreakpointButtonProps) => { 13 | return ( 14 | 21 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/ClearButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Clear } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface ClearButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | } 9 | 10 | export const ClearButton: FC = ({ handleClick, size }) => { 11 | return ( 12 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Edit } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface EditButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | } 9 | 10 | export const EditButton: FC = ({ handleClick, size }) => { 11 | return ( 12 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/RemoveButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@mui/material"; 2 | import { Remove } from "@mui/icons-material"; 3 | import React, { FC } from "react"; 4 | 5 | interface RemoveButtonProps { 6 | handleClick: () => void; 7 | size?: "small" | "medium" | "large"; 8 | } 9 | 10 | export const RemoveButton: FC = ({ handleClick, size }) => { 11 | return ( 12 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/atoms/buttons/buttons.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const NavBarButton = styled.button<{ disabled: boolean }>` 4 | margin-left: 5px; 5 | margin-right: 5px; 6 | padding: 0; 7 | border: none; 8 | background-color: transparent; 9 | cursor: ${({ disabled }) => disabled ? 'default' : 'pointer'}; 10 | width: 24px; 11 | height: 24px; 12 | border-radius: 12px; 13 | outline: none; 14 | color: ${({ disabled }) => disabled ? '#999' : '#333'}; 15 | 16 | ${({ disabled }) => disabled ? null : ` 17 | &:hover { 18 | background-color: #ddd; 19 | } 20 | &:active { 21 | background-color: #d0d0d0; 22 | } 23 | `}; 24 | `; 25 | 26 | export const UrlFormButton = styled.button` 27 | position: absolute; 28 | top: 0; 29 | right: 0; 30 | padding: 0; 31 | border: none; 32 | background-color: transparent; 33 | cursor: pointer; 34 | width: 24px; 35 | height: 24px; 36 | border-radius: 12px; 37 | outline: none; 38 | color: #333; 39 | 40 | &:hover { 41 | background-color: #ddd; 42 | }, 43 | 44 | &:active { 45 | background-color: #d0d0d0; 46 | }, 47 | `; 48 | -------------------------------------------------------------------------------- /src/components/atoms/canvas.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useRef} from 'react'; 2 | import { useSocketStore } from '../../context/socket'; 3 | import { getMappedCoordinates } from "../../helpers/inputHelpers"; 4 | import { useGlobalInfoStore } from "../../context/globalInfo"; 5 | 6 | interface CreateRefCallback { 7 | 8 | (ref: React.RefObject): void; 9 | 10 | } 11 | 12 | interface CanvasProps { 13 | 14 | width: number; 15 | 16 | height: number; 17 | 18 | onCreateRef: CreateRefCallback; 19 | 20 | } 21 | 22 | /** 23 | * Interface for mouse's x,y coordinates 24 | */ 25 | export interface Coordinates { 26 | x: number; 27 | y: number; 28 | }; 29 | 30 | const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { 31 | 32 | const canvasRef = useRef(null); 33 | const { socket } = useSocketStore(); 34 | const { setLastAction, lastAction } = useGlobalInfoStore(); 35 | 36 | const notifyLastAction = (action: string) => { 37 | if (lastAction !== action) { 38 | setLastAction(action); 39 | } 40 | }; 41 | 42 | const lastMousePosition = useRef({ x: 0, y: 0 }); 43 | //const lastWheelPosition = useRef({ deltaX: 0, deltaY: 0 }); 44 | 45 | const onMouseEvent = useCallback((event: MouseEvent) => { 46 | if (socket) { 47 | switch (event.type) { 48 | case 'mousedown': 49 | const clickCoordinates = getMappedCoordinates(event, canvasRef.current, width, height); 50 | socket.emit('input:mousedown', clickCoordinates); 51 | notifyLastAction('click'); 52 | break; 53 | case 'mousemove': 54 | const coordinates = getMappedCoordinates(event, canvasRef.current, width, height); 55 | if (lastMousePosition.current.x !== coordinates.x || 56 | lastMousePosition.current.y !== coordinates.y) { 57 | lastMousePosition.current = coordinates; 58 | socket.emit('input:mousemove', coordinates); 59 | notifyLastAction('move'); 60 | } 61 | break; 62 | case 'wheel': 63 | const wheelEvent = event as WheelEvent; 64 | const deltas = { 65 | deltaX: Math.round(wheelEvent.deltaX), 66 | deltaY: Math.round(wheelEvent.deltaY), 67 | }; 68 | socket.emit('input:wheel', deltas); 69 | notifyLastAction('scroll'); 70 | break; 71 | default: 72 | console.log('Default mouseEvent registered'); 73 | return; 74 | } 75 | } 76 | }, [socket]); 77 | 78 | const onKeyboardEvent = useCallback((event: KeyboardEvent) => { 79 | if (socket) { 80 | switch (event.type) { 81 | case 'keydown': 82 | socket.emit('input:keydown', { key: event.key, coordinates: lastMousePosition.current }); 83 | notifyLastAction(`${event.key} pressed`); 84 | break; 85 | case 'keyup': 86 | socket.emit('input:keyup', event.key); 87 | break; 88 | default: 89 | console.log('Default keyEvent registered'); 90 | return; 91 | } 92 | } 93 | }, [socket]); 94 | 95 | 96 | useEffect(() => { 97 | if (canvasRef.current) { 98 | onCreateRef(canvasRef); 99 | canvasRef.current.addEventListener('mousedown', onMouseEvent); 100 | canvasRef.current.addEventListener('mousemove', onMouseEvent); 101 | canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true }); 102 | canvasRef.current.addEventListener('keydown', onKeyboardEvent); 103 | canvasRef.current.addEventListener('keyup', onKeyboardEvent); 104 | 105 | return () => { 106 | if (canvasRef.current) { 107 | canvasRef.current.removeEventListener('mousedown', onMouseEvent); 108 | canvasRef.current.removeEventListener('mousemove', onMouseEvent); 109 | canvasRef.current.removeEventListener('wheel', onMouseEvent); 110 | canvasRef.current.removeEventListener('keydown', onKeyboardEvent); 111 | canvasRef.current.removeEventListener('keyup', onKeyboardEvent); 112 | } 113 | 114 | }; 115 | }else { 116 | console.log('Canvas not initialized'); 117 | } 118 | 119 | }, [onMouseEvent]); 120 | 121 | return ( 122 | 123 | ); 124 | 125 | }; 126 | 127 | 128 | export default Canvas; 129 | -------------------------------------------------------------------------------- /src/components/atoms/form.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const NavBarForm = styled.form` 4 | flex: 1px; 5 | margin-left: 5px; 6 | margin-right: 5px; 7 | position: relative; 8 | `; 9 | 10 | export const NavBarInput = styled.input` 11 | box-sizing: border-box; 12 | outline: none; 13 | width: 100%; 14 | height: 24px; 15 | border-radius: 12px; 16 | border: none; 17 | padding-left: 12px; 18 | padding-right: 40px; 19 | `; 20 | -------------------------------------------------------------------------------- /src/components/atoms/texts.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const WarningText = styled.p` 4 | border: 1px solid orange; 5 | display: flex; 6 | margin: 10px; 7 | flex-direction: column; 8 | font-size: small; 9 | background: rgba(255,165,0,0.15); 10 | padding: 5px; 11 | font-family: "Roboto","Helvetica","Arial",sans-serif; 12 | font-weight: 400; 13 | line-height: 1.5; 14 | letter-spacing: 0.00938em; 15 | ` 16 | -------------------------------------------------------------------------------- /src/components/molecules/ActionSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import styled from "styled-components"; 3 | import { Button } from "@mui/material"; 4 | import { ActionDescription } from "../organisms/RightSidePanel"; 5 | import * as Settings from "./action-settings"; 6 | import { useSocketStore } from "../../context/socket"; 7 | 8 | interface ActionSettingsProps { 9 | action: string; 10 | } 11 | 12 | export const ActionSettings = ({action}: ActionSettingsProps) => { 13 | 14 | const settingsRef = useRef<{getSettings: () => object}>(null); 15 | const { socket } = useSocketStore(); 16 | 17 | const DisplaySettings = () => { 18 | switch (action) { 19 | case "screenshot": 20 | return ; 21 | case 'scroll': 22 | return ; 23 | case 'scrape': 24 | return ; 25 | case 'scrapeSchema': 26 | return ; 27 | case 'script': 28 | return ; 29 | case 'enqueueLinks': 30 | return ; 31 | case 'mouse.click': 32 | return ; 33 | default: 34 | return null; 35 | } 36 | } 37 | 38 | const handleSubmit = (event: React.SyntheticEvent) => { 39 | event.preventDefault(); 40 | //get the data from settings 41 | const settings = settingsRef.current?.getSettings(); 42 | //Send notification to the server and generate the pair 43 | socket?.emit(`action`, { 44 | action, 45 | settings 46 | }); 47 | } 48 | 49 | return ( 50 |
51 | Action settings: 52 | 53 |
54 | 55 | 67 | 68 |
69 |
70 | ); 71 | }; 72 | 73 | const ActionSettingsWrapper = styled.div<{action: string}>` 74 | display: flex; 75 | flex-direction: column; 76 | align-items: ${({ action }) => action === 'script' ? 'stretch' : 'center'};; 77 | justify-content: center; 78 | margin-top: 20px; 79 | `; 80 | -------------------------------------------------------------------------------- /src/components/molecules/AddWhatCondModal.tsx: -------------------------------------------------------------------------------- 1 | import { WhereWhatPair } from "@wbr-project/wbr-interpret"; 2 | import { GenericModal } from "../atoms/GenericModal"; 3 | import { modalStyle } from "./AddWhereCondModal"; 4 | import { Button, MenuItem, TextField, Typography } from "@mui/material"; 5 | import React, { useRef } from "react"; 6 | import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; 7 | import { KeyValueForm } from "./KeyValueForm"; 8 | import { ClearButton } from "../atoms/buttons/ClearButton"; 9 | import { useSocketStore } from "../../context/socket"; 10 | 11 | interface AddWhatCondModalProps { 12 | isOpen: boolean; 13 | onClose: () => void; 14 | pair: WhereWhatPair; 15 | index: number; 16 | } 17 | 18 | export const AddWhatCondModal = ({isOpen, onClose, pair, index}: AddWhatCondModalProps) => { 19 | const [action, setAction] = React.useState(''); 20 | const [objectIndex, setObjectIndex] = React.useState(0); 21 | const [args, setArgs] = React.useState<({type: string, value: (string|number|object|unknown)})[]>([]); 22 | 23 | const objectRefs = useRef<({getObject: () => object}|unknown)[]>([]); 24 | 25 | const {socket} = useSocketStore(); 26 | 27 | const handleSubmit = () => { 28 | const argsArray: (string|number|object|unknown)[] = []; 29 | args.map((arg, index) => { 30 | switch (arg.type) { 31 | case 'string': 32 | case 'number': 33 | argsArray[index] = arg.value; 34 | break; 35 | case 'object': 36 | // @ts-ignore 37 | argsArray[index] = objectRefs.current[arg.value].getObject(); 38 | } 39 | }) 40 | setArgs([]); 41 | onClose(); 42 | pair.what.push({ 43 | // @ts-ignore 44 | action, 45 | args: argsArray, 46 | }) 47 | socket?.emit('updatePair', {index: index-1, pair: pair}); 48 | } 49 | 50 | return ( 51 | { 52 | setArgs([]); 53 | onClose(); 54 | }} modalStyle={modalStyle}> 55 |
56 | Add what condition: 57 |
58 | Action: 59 | setAction(e.target.value)} 63 | value={action} 64 | label='action' 65 | /> 66 |
67 | Add new argument of type: 68 | 69 | 70 | 74 |
75 | args: 76 | {args.map((arg, index) => { 77 | // @ts-ignore 78 | return ( 79 |
81 | { 82 | args.splice(index,1); 83 | setArgs([...args]); 84 | }}/> 85 | {index}: 86 | {arg.type === 'string' ? 87 | setArgs([ 91 | ...args.slice(0, index), 92 | {type: arg.type, value: e.target.value}, 93 | ...args.slice(index + 1) 94 | ])} 95 | value={args[index].value || ''} 96 | label="string" 97 | key={`arg-${arg.type}-${index}`} 98 | /> : arg.type === 'number' ? 99 | setArgs([ 104 | ...args.slice(0, index), 105 | {type: arg.type, value: Number(e.target.value)}, 106 | ...args.slice(index + 1) 107 | ])} 108 | value={args[index].value || ''} 109 | label="number" 110 | /> : 111 | 112 | //@ts-ignore 113 | objectRefs.current[arg.value] = el} key={`arg-${arg.type}-${index}`}/> 114 | } 115 |
116 | )})} 117 | 129 |
130 |
131 |
132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /src/components/molecules/AddWhereCondModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; 2 | import { 3 | Button, 4 | MenuItem, 5 | Typography 6 | } from "@mui/material"; 7 | import React, { useRef } from "react"; 8 | import { GenericModal } from "../atoms/GenericModal"; 9 | import { WhereWhatPair } from "@wbr-project/wbr-interpret"; 10 | import { SelectChangeEvent } from "@mui/material/Select/Select"; 11 | import { DisplayConditionSettings } from "./DisplayWhereConditionSettings"; 12 | import { useSocketStore } from "../../context/socket"; 13 | 14 | interface AddWhereCondModalProps { 15 | isOpen: boolean; 16 | onClose: () => void; 17 | pair: WhereWhatPair; 18 | index: number; 19 | } 20 | 21 | export const AddWhereCondModal = ({isOpen, onClose, pair, index}: AddWhereCondModalProps) => { 22 | const [whereProp, setWhereProp] = React.useState(''); 23 | const [additionalSettings, setAdditionalSettings] = React.useState(''); 24 | const [newValue, setNewValue] = React.useState(''); 25 | const [checked, setChecked] = React.useState(new Array(Object.keys(pair.where).length).fill(false)); 26 | 27 | const keyValueFormRef = useRef<{getObject: () => object}>(null); 28 | 29 | const {socket} = useSocketStore(); 30 | 31 | const handlePropSelect = (event: SelectChangeEvent) => { 32 | setWhereProp(event.target.value); 33 | switch (event.target.value) { 34 | case 'url': setNewValue(''); break; 35 | case 'selectors': setNewValue(['']); break; 36 | case 'default': return; 37 | } 38 | } 39 | 40 | const handleSubmit = () => { 41 | switch (whereProp) { 42 | case 'url': 43 | if (additionalSettings === 'string'){ 44 | pair.where.url = newValue; 45 | } else { 46 | pair.where.url = { $regex: newValue }; 47 | } 48 | break; 49 | case 'selectors': 50 | pair.where.selectors = newValue; 51 | break; 52 | case 'cookies': 53 | pair.where.cookies = keyValueFormRef.current?.getObject() as Record 54 | break; 55 | case 'before': 56 | pair.where.$before = newValue; 57 | break; 58 | case 'after': 59 | pair.where.$after = newValue; 60 | break; 61 | case 'boolean': 62 | const booleanArr = []; 63 | const deleteKeys: string[] = []; 64 | for (let i = 0; i < checked.length; i++) { 65 | if (checked[i]) { 66 | if (Object.keys(pair.where)[i]) { 67 | //@ts-ignore 68 | if (pair.where[Object.keys(pair.where)[i]]) { 69 | booleanArr.push({ 70 | //@ts-ignore 71 | [Object.keys(pair.where)[i]]: pair.where[Object.keys(pair.where)[i]]}); 72 | } 73 | deleteKeys.push(Object.keys(pair.where)[i]); 74 | } 75 | } 76 | } 77 | // @ts-ignore 78 | deleteKeys.forEach((key: string) => delete pair.where[key]); 79 | //@ts-ignore 80 | pair.where[`$${additionalSettings}`] = booleanArr; 81 | break; 82 | default: 83 | return; 84 | } 85 | onClose(); 86 | setWhereProp(''); 87 | setAdditionalSettings(''); 88 | setNewValue(''); 89 | socket?.emit('updatePair', {index: index-1, pair: pair}); 90 | } 91 | 92 | return ( 93 | { 94 | setWhereProp(''); 95 | setAdditionalSettings(''); 96 | setNewValue(''); 97 | onClose(); 98 | }} modalStyle={modalStyle}> 99 |
100 | Add where condition: 101 |
102 | 107 | url 108 | selectors 109 | cookies 110 | before 111 | after 112 | boolean logic 113 | 114 |
115 | {whereProp ? 116 |
117 | 122 | 134 |
135 | : null} 136 |
137 |
138 | ) 139 | } 140 | 141 | export const modalStyle = { 142 | top: '40%', 143 | left: '50%', 144 | transform: 'translate(-50%, -50%)', 145 | width: '30%', 146 | backgroundColor: 'background.paper', 147 | p: 4, 148 | height:'fit-content', 149 | display:'block', 150 | padding: '20px', 151 | }; 152 | -------------------------------------------------------------------------------- /src/components/molecules/BrowserNavBar.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | FC, 3 | } from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | import ReplayIcon from '@mui/icons-material/Replay'; 7 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 8 | import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; 9 | 10 | import { NavBarButton } from '../atoms/buttons/buttons'; 11 | import { UrlForm } from './UrlForm'; 12 | import { useCallback, useEffect, useState } from "react"; 13 | import {useSocketStore} from "../../context/socket"; 14 | import { getCurrentUrl } from "../../api/recording"; 15 | 16 | const StyledNavBar = styled.div<{ browserWidth: number }>` 17 | display: flex; 18 | padding: 5px; 19 | background-color: #f6f6f6; 20 | width: ${({ browserWidth }) => browserWidth}px; 21 | `; 22 | 23 | interface NavBarProps { 24 | browserWidth: number; 25 | handleUrlChanged: (url: string) => void; 26 | }; 27 | 28 | const BrowserNavBar: FC = ({ 29 | browserWidth, 30 | handleUrlChanged, 31 | }) => { 32 | 33 | // context: 34 | const { socket } = useSocketStore(); 35 | 36 | const [currentUrl, setCurrentUrl] = useState('https://'); 37 | 38 | const handleRefresh = useCallback(() : void => { 39 | socket?.emit('input:refresh'); 40 | }, [socket]); 41 | 42 | const handleGoTo = useCallback((address: string) : void => { 43 | socket?.emit('input:url', address); 44 | }, [socket]); 45 | 46 | const handleCurrentUrlChange = useCallback((url: string) => { 47 | handleUrlChanged(url); 48 | setCurrentUrl(url); 49 | }, [handleUrlChanged, currentUrl]); 50 | 51 | useEffect(() => { 52 | getCurrentUrl().then((response) => { 53 | if (response) { 54 | handleUrlChanged(response); 55 | setCurrentUrl(response); 56 | } 57 | }).catch((error) => { 58 | console.log("Fetching current url failed"); 59 | }) 60 | }, []); 61 | 62 | useEffect(() => { 63 | if (socket) { 64 | socket.on('urlChanged', handleCurrentUrlChange); 65 | } 66 | return () => { 67 | if (socket) { 68 | socket.off('urlChanged', handleCurrentUrlChange); 69 | } 70 | } 71 | }, [socket, handleCurrentUrlChange]) 72 | 73 | const addAddress = (address: string) => { 74 | if (socket) { 75 | handleUrlChanged(address); 76 | handleGoTo(address); 77 | } 78 | }; 79 | 80 | return ( 81 | 82 | { 85 | socket?.emit('input:back'); 86 | }} 87 | disabled={false} 88 | > 89 | 90 | 91 | 92 | { 95 | socket?.emit('input:forward'); 96 | }} 97 | disabled={false} 98 | > 99 | 100 | 101 | 102 | { 105 | if (socket) { 106 | handleRefresh() 107 | } 108 | }} 109 | disabled={false} 110 | > 111 | 112 | 113 | 114 | 119 | 120 | ); 121 | } 122 | 123 | export default BrowserNavBar; 124 | -------------------------------------------------------------------------------- /src/components/molecules/BrowserTabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Box, IconButton, Tab, Tabs } from "@mui/material"; 3 | import { AddButton } from "../atoms/buttons/AddButton"; 4 | import { useBrowserDimensionsStore } from "../../context/browserDimensions"; 5 | import { Close } from "@mui/icons-material"; 6 | 7 | interface BrowserTabsProp { 8 | tabs: string[], 9 | handleTabChange: (index: number) => void, 10 | handleAddNewTab: () => void, 11 | handleCloseTab: (index: number) => void, 12 | handleChangeIndex: (index: number) => void; 13 | tabIndex: number 14 | } 15 | 16 | export const BrowserTabs = ( 17 | { 18 | tabs, handleTabChange, handleAddNewTab, 19 | handleCloseTab, handleChangeIndex, tabIndex 20 | }: BrowserTabsProp) => { 21 | 22 | let tabWasClosed = false; 23 | 24 | const { width } = useBrowserDimensionsStore(); 25 | 26 | const handleChange = (event: React.SyntheticEvent, newValue: number) => { 27 | if (!tabWasClosed) { 28 | handleChangeIndex(newValue); 29 | } 30 | }; 31 | 32 | return ( 33 | 39 | 40 | 44 | {tabs.map((tab, index) => { 45 | return ( 46 | { 50 | tabWasClosed = true; 51 | handleCloseTab(index); 52 | }} disabled={tabs.length === 1} 53 | />} 54 | iconPosition="end" 55 | onClick={() => { 56 | if (!tabWasClosed) { 57 | handleTabChange(index) 58 | } 59 | } 60 | } 61 | label={tab} 62 | /> 63 | ); 64 | })} 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | 72 | interface CloseButtonProps { 73 | closeTab: () => void; 74 | disabled: boolean; 75 | } 76 | 77 | const CloseButton = ({ closeTab, disabled }: CloseButtonProps) => { 78 | return ( 79 | 88 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/components/molecules/ColapsibleRow.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import * as React from "react"; 3 | import TableRow from "@mui/material/TableRow"; 4 | import TableCell from "@mui/material/TableCell"; 5 | import { Box, Collapse, IconButton, Typography } from "@mui/material"; 6 | import { DeleteForever, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; 7 | import { deleteRunFromStorage } from "../../api/storage"; 8 | import { columns, Data } from "./RunsTable"; 9 | import { RunContent } from "./RunContent"; 10 | 11 | interface CollapsibleRowProps { 12 | row: Data; 13 | handleDelete: () => void; 14 | isOpen: boolean; 15 | currentLog: string; 16 | abortRunHandler: () => void; 17 | runningRecordingName: string; 18 | } 19 | export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler,runningRecordingName }: CollapsibleRowProps) => { 20 | const [open, setOpen] = useState(isOpen); 21 | 22 | const logEndRef = useRef(null); 23 | 24 | const scrollToLogBottom = () => { 25 | if (logEndRef.current) { 26 | logEndRef.current.scrollIntoView({ behavior: "smooth" }); 27 | } 28 | } 29 | 30 | const handleAbort = () => { 31 | abortRunHandler(); 32 | } 33 | 34 | useEffect(() => { 35 | scrollToLogBottom(); 36 | }, [currentLog]) 37 | 38 | return ( 39 | 40 | *': { borderBottom: 'unset' } }} hover role="checkbox" tabIndex={-1} key={row.id}> 41 | 42 | { 46 | setOpen(!open); 47 | scrollToLogBottom(); 48 | }} 49 | > 50 | {open ? : } 51 | 52 | 53 | {columns.map((column) => { 54 | // @ts-ignore 55 | const value : any = row[column.id]; 56 | if (value !== undefined) { 57 | return ( 58 | 59 | {value} 60 | 61 | ); 62 | } else { 63 | switch (column.id) { 64 | case 'delete': 65 | return ( 66 | 67 | { 68 | deleteRunFromStorage(`${row.name}_${row.runId}`).then((result: boolean) => { 69 | if (result) { 70 | handleDelete(); 71 | } 72 | }) 73 | }} sx={{'&:hover': { color: '#1976d2', backgroundColor: 'transparent' }}}> 74 | 75 | 76 | 77 | ); 78 | default: 79 | return null; 80 | } 81 | } 82 | })} 83 | 84 | 85 | 86 | 87 | 89 | 90 | 91 | 92 | 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/molecules/DisplayWhereConditionSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Dropdown as MuiDropdown } from "../atoms/DropdownMui"; 3 | import { Checkbox, FormControlLabel, FormGroup, MenuItem, Stack, TextField } from "@mui/material"; 4 | import { AddButton } from "../atoms/buttons/AddButton"; 5 | import { RemoveButton } from "../atoms/buttons/RemoveButton"; 6 | import { KeyValueForm } from "./KeyValueForm"; 7 | import { WarningText } from "../atoms/texts"; 8 | 9 | interface DisplayConditionSettingsProps { 10 | whereProp: string; 11 | additionalSettings: string; 12 | setAdditionalSettings: (value: any) => void; 13 | newValue: any; 14 | setNewValue: (value: any) => void; 15 | keyValueFormRef: React.RefObject<{getObject: () => object}>; 16 | whereKeys: string[]; 17 | checked: boolean[]; 18 | setChecked: (value: boolean[]) => void; 19 | } 20 | 21 | export const DisplayConditionSettings = ( 22 | {whereProp, setAdditionalSettings, additionalSettings, 23 | setNewValue, newValue, keyValueFormRef, whereKeys, checked, setChecked} 24 | : DisplayConditionSettingsProps) => { 25 | switch (whereProp) { 26 | case 'url': 27 | return ( 28 | 29 | setAdditionalSettings(e.target.value)}> 34 | string 35 | regex 36 | 37 | { additionalSettings ? setNewValue(e.target.value)} 41 | value={newValue} 42 | /> : null} 43 | 44 | ) 45 | case 'selectors': 46 | return ( 47 | 48 | 49 | { 50 | newValue.map((selector: string, index: number) => { 51 | return setNewValue([ 56 | ...newValue.slice(0, index), 57 | e.target.value, 58 | ...newValue.slice(index + 1) 59 | ])}/> 60 | }) 61 | } 62 | 63 | setNewValue([...newValue, ''])}/> 64 | { 65 | const arr = newValue; 66 | arr.splice(-1); 67 | setNewValue([...arr]); 68 | }}/> 69 | 70 | ) 71 | case 'cookies': 72 | return 73 | case 'before': 74 | return setNewValue(e.target.value)} 79 | /> 80 | case 'after': 81 | return setNewValue(e.target.value)} 86 | /> 87 | case 'boolean': 88 | return ( 89 | 90 | setAdditionalSettings(e.target.value)}> 95 | and 96 | or 97 | 98 | 99 | { 100 | whereKeys.map((key: string, index: number) => { 101 | return ( 102 | setChecked([ 106 | ...checked.slice(0, index), 107 | !checked[index], 108 | ...checked.slice(index + 1) 109 | ])} 110 | key={`checkbox-${key}-${index}`} 111 | /> 112 | } label={key} key={`control-label-form-${key}-${index}`}/> 113 | ) 114 | }) 115 | } 116 | 117 | 118 | Choose at least 2 where conditions. Nesting of boolean operators 119 | is possible by adding more conditions. 120 | 121 | 122 | ) 123 | default: 124 | return null; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/components/molecules/InterpretationLog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Accordion from '@mui/material/Accordion'; 3 | import AccordionDetails from '@mui/material/AccordionDetails'; 4 | import AccordionSummary from '@mui/material/AccordionSummary'; 5 | import Typography from '@mui/material/Typography'; 6 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 7 | import Highlight from 'react-highlight' 8 | import { useCallback, useEffect, useRef, useState } from "react"; 9 | import { useSocketStore } from "../../context/socket"; 10 | 11 | export const InterpretationLog = () => { 12 | const [expanded, setExpanded] = useState(false); 13 | const [log, setLog] = useState(''); 14 | 15 | const logEndRef = useRef(null); 16 | 17 | const handleChange = (isExpanded: boolean) => (event: React.SyntheticEvent) => { 18 | setExpanded(isExpanded); 19 | }; 20 | 21 | const { socket } = useSocketStore(); 22 | 23 | const scrollLogToBottom = () => { 24 | if (logEndRef.current) { 25 | logEndRef.current.scrollIntoView({ behavior: "smooth" }) 26 | } 27 | } 28 | 29 | const handleLog = useCallback((msg: string, date: boolean = true) => { 30 | if (!date){ 31 | setLog((prevState) => prevState + '\n' + msg); 32 | } else { 33 | setLog((prevState) => prevState + '\n' + `[${new Date().toLocaleString()}] ` + msg); 34 | } 35 | scrollLogToBottom(); 36 | }, [log, scrollLogToBottom]) 37 | 38 | const handleSerializableCallback = useCallback((data: string) => { 39 | setLog((prevState) => 40 | prevState + '\n' + '---------- Serializable output data received ----------' + '\n' 41 | + JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------'); 42 | scrollLogToBottom(); 43 | }, [log, scrollLogToBottom]) 44 | 45 | const handleBinaryCallback = useCallback(({data, mimetype}: any) => { 46 | setLog((prevState) => 47 | prevState + '\n' + '---------- Binary output data received ----------' + '\n' 48 | + `mimetype: ${mimetype}` + '\n' + `data: ${JSON.stringify(data)}` + '\n' 49 | + '------------------------------------------------'); 50 | scrollLogToBottom(); 51 | }, [log, scrollLogToBottom]) 52 | 53 | useEffect(() => { 54 | socket?.on('log', handleLog); 55 | socket?.on('serializableCallback', handleSerializableCallback); 56 | socket?.on('binaryCallback', handleBinaryCallback); 57 | return () => { 58 | socket?.off('log', handleLog); 59 | socket?.off('serializableCallback', handleSerializableCallback); 60 | socket?.off('binaryCallback', handleBinaryCallback); 61 | } 62 | }, [socket, handleLog]) 63 | 64 | return ( 65 |
66 | 71 | } 73 | aria-controls="panel1bh-content" 74 | id="panel1bh-header" 75 | > 76 | 77 | Interpretation Log 78 | 79 | 80 | 87 |
88 | 89 | {log} 90 | 91 |
93 |
94 | 95 | 96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/components/molecules/KeyValueForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle, useRef } from 'react'; 2 | import { KeyValuePair } from "../atoms/KeyValuePair"; 3 | import { AddButton } from "../atoms/buttons/AddButton"; 4 | import { RemoveButton } from "../atoms/buttons/RemoveButton"; 5 | 6 | export const KeyValueForm = forwardRef((props, ref) => { 7 | const [numberOfPairs, setNumberOfPairs] = React.useState(1); 8 | const keyValuePairRefs = useRef<{getKeyValuePair: () => { key: string, value: string }}[]>([]); 9 | 10 | useImperativeHandle(ref, () => ({ 11 | getObject() { 12 | let reducedObject = {}; 13 | for (let i = 0; i < numberOfPairs; i++) { 14 | const keyValuePair = keyValuePairRefs.current[i]?.getKeyValuePair(); 15 | if (keyValuePair) { 16 | reducedObject = { 17 | ...reducedObject, 18 | [keyValuePair.key]: keyValuePair.value 19 | } 20 | } 21 | } 22 | return reducedObject; 23 | } 24 | })); 25 | 26 | return ( 27 |
28 | { 29 | new Array(numberOfPairs).fill(1).map((_, index) => { 30 | return keyValuePairRefs.current[index] = el}/> 33 | }) 34 | } 35 | setNumberOfPairs(numberOfPairs + 1)} hoverEffect={false}/> 36 | setNumberOfPairs(numberOfPairs - 1)}/> 37 |
38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/molecules/LeftSidePanelContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import Box from "@mui/material/Box"; 3 | import { Pair } from "./Pair"; 4 | import { WhereWhatPair, WorkflowFile } from "@wbr-project/wbr-interpret"; 5 | import { useSocketStore } from "../../context/socket"; 6 | import { Add } from "@mui/icons-material"; 7 | import { Socket } from "socket.io-client"; 8 | import { AddButton } from "../atoms/buttons/AddButton"; 9 | import { AddPair } from "../../api/workflow"; 10 | import { GenericModal } from "../atoms/GenericModal"; 11 | import { PairEditForm } from "./PairEditForm"; 12 | import { Fab, Tooltip, Typography } from "@mui/material"; 13 | 14 | interface LeftSidePanelContentProps { 15 | workflow: WorkflowFile; 16 | updateWorkflow: (workflow: WorkflowFile) => void; 17 | recordingName: string; 18 | handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void; 19 | } 20 | 21 | export const LeftSidePanelContent = ({ workflow, updateWorkflow, recordingName, handleSelectPairForEdit}: LeftSidePanelContentProps) => { 22 | const [activeId, setActiveId] = React.useState(0); 23 | const [breakpoints, setBreakpoints] = React.useState([]); 24 | const [showEditModal, setShowEditModal] = useState(false); 25 | 26 | const { socket } = useSocketStore(); 27 | 28 | const activePairIdHandler = useCallback((data: string, socket: Socket) => { 29 | setActiveId(parseInt(data) + 1); 30 | // -1 is specially emitted when the interpretation finishes 31 | if (parseInt(data) === -1) { 32 | return; 33 | } 34 | socket.emit('activeIndex', data); 35 | }, [activeId]) 36 | 37 | const addPair = (pair: WhereWhatPair, index: number) => { 38 | AddPair((index - 1), pair).then((updatedWorkflow) => { 39 | updateWorkflow(updatedWorkflow); 40 | }).catch((error) => { 41 | console.error(error); 42 | }); 43 | setShowEditModal(false); 44 | }; 45 | 46 | useEffect(() => { 47 | socket?.on("activePairId", (data) => activePairIdHandler(data, socket)); 48 | return () => { 49 | socket?.off("activePairId", (data) => activePairIdHandler(data, socket)); 50 | } 51 | }, [socket, setActiveId]); 52 | 53 | 54 | const handleBreakpointClick = (id: number) => { 55 | setBreakpoints(oldBreakpoints => { 56 | const newArray = [...oldBreakpoints, ...Array(workflow.workflow.length - oldBreakpoints.length).fill(false)]; 57 | newArray[id] = !newArray[id]; 58 | socket?.emit("breakpoints", newArray); 59 | return newArray; 60 | }); 61 | }; 62 | 63 | const handleAddPair = () => { 64 | setShowEditModal(true); 65 | }; 66 | 67 | return ( 68 |
69 | 70 |
71 | 77 |
78 |
79 | setShowEditModal(false)} 82 | > 83 | 87 | 88 |
89 | { 90 | workflow.workflow.map((pair, i, workflow, ) => 91 | handleBreakpointClick(i)} 93 | isActive={ activeId === i + 1} 94 | key={workflow.length - i} 95 | index={workflow.length - i} 96 | pair={pair} 97 | updateWorkflow={updateWorkflow} 98 | numberOfPairs={workflow.length} 99 | handleSelectPairForEdit={handleSelectPairForEdit} 100 | />) 101 | } 102 |
103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /src/components/molecules/LeftSidePanelSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, MenuItem, TextField, Typography } from "@mui/material"; 3 | import { Dropdown } from "../atoms/DropdownMui"; 4 | import { RunSettings } from "./RunSettings"; 5 | import { useSocketStore } from "../../context/socket"; 6 | 7 | interface LeftSidePanelSettingsProps { 8 | params: any[] 9 | settings: RunSettings, 10 | setSettings: (setting: RunSettings) => void 11 | } 12 | 13 | export const LeftSidePanelSettings = ({params, settings, setSettings}: LeftSidePanelSettingsProps) => { 14 | const { socket } = useSocketStore(); 15 | 16 | return ( 17 |
18 | { params.length !== 0 && ( 19 | 20 | Parameters: 21 | { params?.map((item: string, index: number) => { 22 | return setSettings( 30 | { 31 | ...settings, 32 | params: settings.params 33 | ? { 34 | ...settings.params, 35 | [item]: e.target.value, 36 | } 37 | : { 38 | [item]: e.target.value, 39 | }, 40 | })} 41 | /> 42 | }) } 43 | 44 | )} 45 | Interpreter: 46 | setSettings( 51 | { 52 | ...settings, 53 | maxConcurrency: parseInt(e.target.value), 54 | })} 55 | defaultValue={settings.maxConcurrency} 56 | /> 57 | setSettings( 63 | { 64 | ...settings, 65 | maxRepeats: parseInt(e.target.value), 66 | })} 67 | defaultValue={settings.maxRepeats} 68 | /> 69 | setSettings( 74 | { 75 | ...settings, 76 | debug: e.target.value === "true", 77 | })} 78 | > 79 | true 80 | false 81 | 82 | 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/molecules/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from "styled-components"; 3 | import { stopRecording } from "../../api/recording"; 4 | import { useGlobalInfoStore } from "../../context/globalInfo"; 5 | import { Button, IconButton } from "@mui/material"; 6 | import { RecordingIcon } from "../atoms/RecorderIcon"; 7 | import { SaveRecording } from "./SaveRecording"; 8 | import { Circle } from "@mui/icons-material"; 9 | import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'; 10 | 11 | interface NavBarProps { 12 | newRecording: () => void; 13 | recordingName: string; 14 | isRecording: boolean; 15 | } 16 | 17 | export const NavBar = ({newRecording, recordingName, isRecording}:NavBarProps) => { 18 | 19 | const { notify, browserId, setBrowserId, recordingLength } = useGlobalInfoStore(); 20 | 21 | // If recording is in progress, the resources and change page view by setting browserId to null 22 | // else it won't affect the page 23 | const goToMainMenu = async() => { 24 | if (browserId) { 25 | await stopRecording(browserId); 26 | notify('warning', 'Current Recording was terminated'); 27 | setBrowserId(null); 28 | } 29 | }; 30 | 31 | const handleNewRecording = async () => { 32 | if (browserId) { 33 | setBrowserId(null); 34 | await stopRecording(browserId); 35 | } 36 | newRecording(); 37 | notify('info', 'New Recording started'); 38 | } 39 | 40 | return ( 41 | 42 |
46 | 47 |
Browser Recorder
48 |
49 |
53 | 72 | {isRecording ? 'NEW' : 'RECORD'} 73 | 74 | { 75 | recordingLength > 0 76 | ? 77 | :null 78 | } 79 | { isRecording ? 92 | : null } 93 |
94 | 95 |
96 | ); 97 | }; 98 | 99 | const NavBarWrapper = styled.div` 100 | grid-area: navbar; 101 | background-color: #3f4853; 102 | padding:5px; 103 | display: flex; 104 | justify-content: space-between; 105 | `; 106 | 107 | const ProjectName = styled.b` 108 | color: white; 109 | font-size: 1.3em; 110 | `; 111 | -------------------------------------------------------------------------------- /src/components/molecules/Pair.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { Stack, Button, IconButton, Tooltip, Chip, Badge } from "@mui/material"; 3 | import { AddPair, deletePair, UpdatePair } from "../../api/workflow"; 4 | import { WorkflowFile } from "@wbr-project/wbr-interpret"; 5 | import { ClearButton } from "../atoms/buttons/ClearButton"; 6 | import { GenericModal } from "../atoms/GenericModal"; 7 | import { PairEditForm } from "./PairEditForm"; 8 | import { PairDisplayDiv } from "../atoms/PairDisplayDiv"; 9 | import { EditButton } from "../atoms/buttons/EditButton"; 10 | import { BreakpointButton } from "../atoms/buttons/BreakpointButton"; 11 | import VisibilityIcon from '@mui/icons-material/Visibility'; 12 | import styled from "styled-components"; 13 | import { LoadingButton } from "@mui/lab"; 14 | 15 | type WhereWhatPair = WorkflowFile["workflow"][number]; 16 | 17 | 18 | interface PairProps { 19 | handleBreakpoint: () => void; 20 | isActive: boolean; 21 | index: number; 22 | pair: WhereWhatPair; 23 | updateWorkflow: (workflow: WorkflowFile) => void; 24 | numberOfPairs: number; 25 | handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void; 26 | } 27 | 28 | 29 | export const Pair: FC = ( 30 | { 31 | handleBreakpoint, isActive, index, 32 | pair, updateWorkflow, numberOfPairs, 33 | handleSelectPairForEdit 34 | }) => { 35 | const [open, setOpen] = useState(false); 36 | const [edit, setEdit] = useState(false); 37 | const [breakpoint, setBreakpoint] = useState(false); 38 | 39 | const enableEdit = () => setEdit(true); 40 | const disableEdit = () => setEdit(false); 41 | 42 | const handleOpen = () => setOpen(true); 43 | const handleClose = () => { 44 | setOpen(false); 45 | disableEdit(); 46 | } 47 | 48 | const handleDelete = () => { 49 | deletePair(index - 1).then((updatedWorkflow) => { 50 | updateWorkflow(updatedWorkflow); 51 | }).catch((error) => { 52 | console.error(error); 53 | }); 54 | }; 55 | 56 | const handleEdit = (pair: WhereWhatPair, newIndex: number) => { 57 | if (newIndex !== index){ 58 | AddPair((newIndex - 1), pair).then((updatedWorkflow) => { 59 | updateWorkflow(updatedWorkflow); 60 | }).catch((error) => { 61 | console.error(error); 62 | }); 63 | } else { 64 | UpdatePair((index - 1), pair).then((updatedWorkflow) => { 65 | updateWorkflow(updatedWorkflow); 66 | }).catch((error) => { 67 | console.error(error); 68 | }); 69 | } 70 | handleClose(); 71 | }; 72 | 73 | const handleBreakpointClick = () => { 74 | setBreakpoint(!breakpoint); 75 | handleBreakpoint(); 76 | }; 77 | 78 | return ( 79 | 80 | 81 |
82 | {isActive ? 83 | : breakpoint ? 84 | : 85 | } 86 |
87 | 88 | 98 | 99 | 106 | 107 |
108 | 111 |
112 |
113 | 114 | 115 |
116 | { 118 | enableEdit(); 119 | handleOpen(); 120 | }} 121 | /> 122 |
123 |
124 | 125 |
126 | 127 |
128 |
129 |
130 |
131 | 132 | { edit 133 | ? 134 | 142 | : 143 |
144 | 148 |
149 | } 150 |
151 |
152 | ); 153 | }; 154 | 155 | interface ViewButtonProps { 156 | handleClick: () => void; 157 | } 158 | 159 | const ViewButton = ({handleClick}: ViewButtonProps) => { 160 | return ( 161 | 163 | 164 | 165 | ); 166 | } 167 | 168 | 169 | const PairWrapper = styled.div<{ isActive: boolean }>` 170 | background-color: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent' }; 171 | border: ${({ isActive }) => isActive ? 'solid 2px red' : 'none' }; 172 | display: flex; 173 | flex-direction: row; 174 | flex-grow: 1; 175 | width: 98%; 176 | color: gray; 177 | &:hover { 178 | color: dimgray; 179 | background: ${({ isActive }) => isActive ? 'rgba(255, 0, 0, 0.1)' : 'transparent' }; 180 | } 181 | `; 182 | -------------------------------------------------------------------------------- /src/components/molecules/PairEditForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button, TextField, Typography } from "@mui/material"; 2 | import React, { FC } from "react"; 3 | import { Preprocessor, WhereWhatPair } from "@wbr-project/wbr-interpret"; 4 | 5 | interface PairProps { 6 | index: string; 7 | id?: string; 8 | where: string | null; 9 | what: string | null; 10 | } 11 | 12 | interface PairEditFormProps { 13 | onSubmitOfPair: (value: WhereWhatPair, index: number) => void; 14 | numberOfPairs: number; 15 | index?: string; 16 | where?: string; 17 | what?: string; 18 | id?: string; 19 | } 20 | 21 | export const PairEditForm: FC = ( 22 | { 23 | onSubmitOfPair, 24 | numberOfPairs, 25 | index, 26 | where, 27 | what, 28 | id, 29 | }) => { 30 | const [pairProps, setPairProps] = React.useState({ 31 | where: where || null, 32 | what: what || null, 33 | index: index || "1", 34 | id: id || '', 35 | }); 36 | const [errors, setErrors] = React.useState({ 37 | where: null, 38 | what: null, 39 | index: '', 40 | }); 41 | 42 | const handleInputChange = (event: React.ChangeEvent) => { 43 | const { id, value } = event.target; 44 | if (id === 'index') { 45 | if (parseInt(value, 10) < 1) { 46 | setErrors({ ...errors, index: 'Index must be greater than 0' }); 47 | return; 48 | } else { 49 | setErrors({ ...errors, index: '' }); 50 | } 51 | } 52 | setPairProps({ ...pairProps, [id]: value }); 53 | }; 54 | 55 | const validateAndSubmit = (event: React.SyntheticEvent) => { 56 | event.preventDefault(); 57 | let whereFromPair, whatFromPair; 58 | // validate where 59 | whereFromPair = { 60 | where: pairProps.where && pairProps.where !== '{"url":"","selectors":[""] }' 61 | ? JSON.parse(pairProps.where) 62 | : {}, 63 | what: [], 64 | }; 65 | const validationError = Preprocessor.validateWorkflow({workflow: [whereFromPair]}); 66 | setErrors({ ...errors, where: null }); 67 | if (validationError) { 68 | setErrors({ ...errors, where: validationError.message }); 69 | return; 70 | } 71 | // validate what 72 | whatFromPair = { 73 | where: {}, 74 | what: pairProps.what && pairProps.what !== '[{"action":"","args":[""] }]' 75 | ? JSON.parse(pairProps.what): [], 76 | }; 77 | const validationErrorWhat = Preprocessor.validateWorkflow({workflow: [whatFromPair]}); 78 | setErrors({ ...errors, "what": null }); 79 | if (validationErrorWhat) { 80 | setErrors({ ...errors, what: validationErrorWhat.message }); 81 | return; 82 | } 83 | //validate index 84 | const index = parseInt(pairProps?.index, 10); 85 | if (index > (numberOfPairs + 1)) { 86 | if (numberOfPairs === 0) { 87 | setErrors(prevState => ({ 88 | ...prevState, 89 | index: 'Index of the first pair must be 1' 90 | })); 91 | return; 92 | } else { 93 | setErrors(prevState => ({ 94 | ...prevState, 95 | index: `Index must be in the range 1-${numberOfPairs + 1}` 96 | })); 97 | return; 98 | } 99 | } else { 100 | setErrors({ ...errors, index: '' }); 101 | } 102 | // submit the pair 103 | onSubmitOfPair(pairProps.id 104 | ? { 105 | id: pairProps.id, 106 | where: whereFromPair?.where || {}, 107 | what: whatFromPair?.what || [], 108 | } 109 | : { 110 | where: whereFromPair?.where || {}, 111 | what: whatFromPair?.what || [], 112 | } 113 | , index); 114 | }; 115 | 116 | return ( 117 |
125 | Raw pair edit form: 126 | 138 | 144 | 148 | 152 | 159 | 160 | ); 161 | }; 162 | -------------------------------------------------------------------------------- /src/components/molecules/RunSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { GenericModal } from "../atoms/GenericModal"; 3 | import { MenuItem, TextField, Typography } from "@mui/material"; 4 | import { Dropdown } from "../atoms/DropdownMui"; 5 | import Button from "@mui/material/Button"; 6 | import { modalStyle } from "./AddWhereCondModal"; 7 | 8 | interface RunSettingsProps { 9 | isOpen: boolean; 10 | handleStart: (settings: RunSettings) => void; 11 | handleClose: () => void; 12 | isTask: boolean; 13 | params?: string[]; 14 | } 15 | 16 | export interface RunSettings { 17 | maxConcurrency: number; 18 | maxRepeats: number; 19 | debug: boolean; 20 | params?: any; 21 | } 22 | 23 | export const RunSettingsModal = ({ isOpen, handleStart, handleClose, isTask, params }: RunSettingsProps) => { 24 | 25 | const [settings, setSettings] = React.useState({ 26 | maxConcurrency: 1, 27 | maxRepeats: 1, 28 | debug: true, 29 | }); 30 | 31 | return ( 32 | 37 |
43 | { isTask 44 | ? 45 | ( 46 | 47 | Recording parameters: 48 | { params?.map((item, index) => { 49 | return setSettings( 56 | { 57 | ...settings, 58 | params: settings.params 59 | ? { 60 | ...settings.params, 61 | [item]: e.target.value, 62 | } 63 | : { 64 | [item]: e.target.value, 65 | }, 66 | })} 67 | /> 68 | }) } 69 | ) 70 | : null 71 | } 72 | Interpreter settings: 73 | setSettings( 79 | { 80 | ...settings, 81 | maxConcurrency: parseInt(e.target.value), 82 | })} 83 | defaultValue={settings.maxConcurrency} 84 | /> 85 | setSettings( 91 | { 92 | ...settings, 93 | maxRepeats: parseInt(e.target.value), 94 | })} 95 | defaultValue={settings.maxRepeats} 96 | /> 97 | setSettings( 102 | { 103 | ...settings, 104 | debug: e.target.value === "true", 105 | })} 106 | > 107 | true 108 | false 109 | 110 | 111 |
112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /src/components/molecules/RunsTable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Paper from '@mui/material/Paper'; 3 | import Table from '@mui/material/Table'; 4 | import TableBody from '@mui/material/TableBody'; 5 | import TableCell from '@mui/material/TableCell'; 6 | import TableContainer from '@mui/material/TableContainer'; 7 | import TableHead from '@mui/material/TableHead'; 8 | import TablePagination from '@mui/material/TablePagination'; 9 | import TableRow from '@mui/material/TableRow'; 10 | import { useEffect, useState } from "react"; 11 | import { useGlobalInfoStore } from "../../context/globalInfo"; 12 | import { getStoredRuns } from "../../api/storage"; 13 | import { RunSettings } from "./RunSettings"; 14 | import { CollapsibleRow } from "./ColapsibleRow"; 15 | 16 | interface Column { 17 | id: 'status' | 'name' | 'startedAt' | 'finishedAt' | 'duration' | 'task' | 'runId' | 'delete'; 18 | label: string; 19 | minWidth?: number; 20 | align?: 'right'; 21 | format?: (value: string) => string; 22 | } 23 | 24 | export const columns: readonly Column[] = [ 25 | { id: 'status', label: 'Status', minWidth: 80 }, 26 | { id: 'name', label: 'Name', minWidth: 80 }, 27 | { id: 'startedAt', label: 'Started at', minWidth: 80 }, 28 | { id: 'finishedAt', label: 'Finished at', minWidth: 80 }, 29 | { id: 'duration', label: 'Duration', minWidth: 80 }, 30 | { id: 'runId', label: 'Run id', minWidth: 80 }, 31 | { id: 'task', label: 'Task', minWidth: 80 }, 32 | { id: 'delete', label: 'Delete', minWidth: 80 }, 33 | ]; 34 | 35 | export interface Data { 36 | id: number; 37 | status: string; 38 | name: string; 39 | startedAt: string; 40 | finishedAt: string; 41 | duration: string; 42 | task: string; 43 | log: string; 44 | runId: string; 45 | interpreterSettings: RunSettings; 46 | serializableOutput: any; 47 | binaryOutput: any; 48 | } 49 | 50 | interface RunsTableProps { 51 | currentInterpretationLog: string; 52 | abortRunHandler: () => void; 53 | runId: string; 54 | runningRecordingName: string; 55 | } 56 | 57 | export const RunsTable = ( 58 | { currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsTableProps) => { 59 | const [page, setPage] = useState(0); 60 | const [rowsPerPage, setRowsPerPage] = useState(10); 61 | const [rows, setRows] = useState([]); 62 | 63 | const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); 64 | 65 | const handleChangePage = (event: unknown, newPage: number) => { 66 | setPage(newPage); 67 | }; 68 | 69 | const handleChangeRowsPerPage = (event: React.ChangeEvent) => { 70 | setRowsPerPage(+event.target.value); 71 | setPage(0); 72 | }; 73 | 74 | const fetchRuns = async () => { 75 | const runs = await getStoredRuns(); 76 | if (runs) { 77 | const parsedRows: Data[] = []; 78 | runs.map((run, index) => { 79 | const parsedRun = JSON.parse(run); 80 | parsedRows.push({ 81 | id: index, 82 | ...parsedRun, 83 | }); 84 | }); 85 | setRows(parsedRows); 86 | } else { 87 | console.log('No runs found.'); 88 | } 89 | } 90 | 91 | useEffect( () => { 92 | if (rows.length === 0 || rerenderRuns) { 93 | fetchRuns(); 94 | setRerenderRuns(false); 95 | } 96 | 97 | }, [rerenderRuns]); 98 | 99 | 100 | const handleDelete = () => { 101 | setRows([]); 102 | notify('success', 'Run deleted successfully'); 103 | fetchRuns(); 104 | } 105 | 106 | return ( 107 | 108 | 109 | 110 | 111 | 112 | 113 | {columns.map((column) => ( 114 | 119 | {column.label} 120 | 121 | ))} 122 | 123 | 124 | 125 | {rows.length !== 0 ? rows 126 | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) 127 | .map((row, index) => 128 | 137 | ) 138 | : null } 139 | 140 |
141 |
142 | 151 |
152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /src/components/molecules/SaveRecording.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { IconButton, Button, Box, LinearProgress, Tooltip } from "@mui/material"; 3 | import { GenericModal } from "../atoms/GenericModal"; 4 | import { stopRecording } from "../../api/recording"; 5 | import { useGlobalInfoStore } from "../../context/globalInfo"; 6 | import { useSocketStore } from "../../context/socket"; 7 | import { TextField, Typography } from "@mui/material"; 8 | import { WarningText } from "../atoms/texts"; 9 | import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; 10 | import FlagIcon from '@mui/icons-material/Flag'; 11 | 12 | interface SaveRecordingProps { 13 | fileName: string; 14 | } 15 | 16 | export const SaveRecording = ({fileName}: SaveRecordingProps) => { 17 | 18 | const [openModal, setOpenModal] = useState(false); 19 | const [needConfirm, setNeedConfirm] = useState(false); 20 | const [recordingName, setRecordingName] = useState(fileName); 21 | const [waitingForSave, setWaitingForSave] = useState(false); 22 | 23 | const { browserId, setBrowserId, notify, recordings } = useGlobalInfoStore(); 24 | const { socket } = useSocketStore(); 25 | 26 | const handleChangeOfTitle = (event: React.ChangeEvent) => { 27 | const { value } = event.target; 28 | if (needConfirm) { 29 | setNeedConfirm(false); 30 | } 31 | setRecordingName(value); 32 | } 33 | 34 | const handleSaveRecording = async (event: React.SyntheticEvent) => { 35 | event.preventDefault(); 36 | if (recordings.includes(recordingName)) { 37 | if (needConfirm) { return; } 38 | setNeedConfirm(true); 39 | } else { 40 | await saveRecording(); 41 | } 42 | }; 43 | 44 | const exitRecording = useCallback(async() => { 45 | notify('success', 'Recording saved successfully'); 46 | if (browserId) { 47 | await stopRecording(browserId); 48 | } 49 | setBrowserId(null); 50 | }, [setBrowserId, browserId, notify]); 51 | 52 | // notifies backed to save the recording in progress, 53 | // releases resources and changes the view for main page by clearing the global browserId 54 | const saveRecording = async () => { 55 | socket?.emit('save', recordingName) 56 | setWaitingForSave(true); 57 | } 58 | 59 | useEffect(() => { 60 | socket?.on('fileSaved', exitRecording); 61 | return () => { 62 | socket?.off('fileSaved', exitRecording); 63 | } 64 | }, [socket, exitRecording]); 65 | 66 | return ( 67 |
68 | 78 | 79 | setOpenModal(false)} modalStyle={modalStyle}> 80 |
81 | Save the recording as: 82 | 91 | { needConfirm 92 | ? 93 | ( 94 | 95 | 96 | 97 | Recording already exists, please confirm the recording's overwrite. 98 | 99 | ) 100 | : 101 | } 102 | { waitingForSave && 103 | 104 | 105 | 106 | 107 | 108 | } 109 | 110 |
111 |
112 | ); 113 | } 114 | 115 | const modalStyle = { 116 | top: '25%', 117 | left: '50%', 118 | transform: 'translate(-50%, -50%)', 119 | width: '20%', 120 | backgroundColor: 'background.paper', 121 | p: 4, 122 | height:'fit-content', 123 | display:'block', 124 | padding: '20px', 125 | }; 126 | -------------------------------------------------------------------------------- /src/components/molecules/SidePanelHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { InterpretationButtons } from "./InterpretationButtons"; 3 | import { AddButton } from "../atoms/buttons/AddButton"; 4 | import { GenericModal } from "../atoms/GenericModal"; 5 | import { PairEditForm } from "./PairEditForm"; 6 | import { WhereWhatPair, WorkflowFile } from "@wbr-project/wbr-interpret"; 7 | import { AddPair } from "../../api/workflow"; 8 | import { Button, Stack } from "@mui/material"; 9 | import { FastForward } from "@mui/icons-material"; 10 | import { useSocketStore } from "../../context/socket"; 11 | import { useGlobalInfoStore } from "../../context/globalInfo"; 12 | 13 | export const SidePanelHeader = () => { 14 | 15 | const [steppingIsDisabled, setSteppingIsDisabled] = useState(true); 16 | 17 | const { socket } = useSocketStore(); 18 | 19 | const handleStep = () => { 20 | socket?.emit('step'); 21 | }; 22 | 23 | return ( 24 |
25 | setSteppingIsDisabled(!isPaused)}/> 26 | 35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/molecules/ToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react"; 2 | import styled from "styled-components"; 3 | 4 | interface ToggleButtonProps { 5 | isChecked?: boolean; 6 | onChange: () => void; 7 | }; 8 | 9 | export const ToggleButton: FC = ({ isChecked = false, onChange }) => ( 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | const CheckBoxWrapper = styled.div` 17 | position: relative; 18 | `; 19 | const CheckBoxLabel = styled.label` 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | width: 42px; 24 | height: 26px; 25 | border-radius: 15px; 26 | background: #bebebe; 27 | cursor: pointer; 28 | 29 | &::after { 30 | content: ""; 31 | display: block; 32 | border-radius: 50%; 33 | width: 18px; 34 | height: 18px; 35 | margin: 3px; 36 | background: #ffffff; 37 | box-shadow: 1px 3px 3px 1px rgba(0, 0, 0, 0.2); 38 | transition: 0.2s; 39 | } 40 | `; 41 | const CheckBox = styled.input` 42 | opacity: 0; 43 | z-index: 1; 44 | border-radius: 15px; 45 | width: 42px; 46 | height: 26px; 47 | 48 | &:checked + ${CheckBoxLabel} { 49 | background: #2196F3; 50 | 51 | &::after { 52 | content: ""; 53 | display: block; 54 | border-radius: 50%; 55 | width: 18px; 56 | height: 18px; 57 | margin-left: 21px; 58 | transition: 0.2s; 59 | } 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /src/components/molecules/UrlForm.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useCallback, useEffect,} from 'react'; 2 | import type { SyntheticEvent, } from 'react'; 3 | import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; 4 | 5 | import { NavBarForm, NavBarInput } from "../atoms/form"; 6 | import { UrlFormButton } from "../atoms/buttons/buttons"; 7 | import { useSocketStore } from '../../context/socket'; 8 | import {Socket} from "socket.io-client"; 9 | 10 | type Props = { 11 | currentAddress: string; 12 | handleRefresh: (socket: Socket) => void; 13 | setCurrentAddress: (address: string) => void; 14 | }; 15 | 16 | export const UrlForm = ({ 17 | currentAddress, 18 | handleRefresh, 19 | setCurrentAddress, 20 | }: Props) => { 21 | // states: 22 | const [address, setAddress] = useState(currentAddress); 23 | // context: 24 | const { socket } = useSocketStore(); 25 | 26 | const areSameAddresses = address === currentAddress; 27 | 28 | const onChange = useCallback((event: SyntheticEvent): void => { 29 | setAddress((event.target as HTMLInputElement).value); 30 | }, [address]); 31 | 32 | const onSubmit = (event: SyntheticEvent): void => { 33 | event.preventDefault(); 34 | let url = address; 35 | 36 | // add protocol if missing 37 | if (!/^(?:f|ht)tps?\:\/\//.test(address)) { 38 | url = "https://" + address; 39 | setAddress(url); 40 | } 41 | 42 | if (areSameAddresses) { 43 | if (socket) { 44 | handleRefresh(socket); 45 | } 46 | } else { 47 | try { 48 | // try the validity of url 49 | new URL(url); 50 | setCurrentAddress(url); 51 | } catch (e) { 52 | alert(`ERROR: ${url} is not a valid url!`); 53 | } 54 | } 55 | }; 56 | 57 | useEffect(() => { 58 | setAddress(currentAddress) 59 | }, [currentAddress]); 60 | 61 | return ( 62 | 63 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/clickOnCoordinates.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { Stack, TextField } from "@mui/material"; 3 | import { WarningText } from '../../atoms/texts'; 4 | import InfoIcon from "@mui/icons-material/Info"; 5 | 6 | export const ClickOnCoordinatesSettings = forwardRef((props, ref) => { 7 | const [settings, setSettings] = React.useState([0,0]); 8 | useImperativeHandle(ref, () => ({ 9 | getSettings() { 10 | return settings; 11 | } 12 | })); 13 | 14 | return ( 15 | 16 | setSettings(prevState => ([Number(e.target.value), prevState[1]]))} 21 | required 22 | defaultValue={settings[0]} 23 | /> 24 | setSettings(prevState => ([prevState[0], Number(e.target.value)]))} 29 | required 30 | defaultValue={settings[1]} 31 | /> 32 | 33 | 34 | The click function will click on the given coordinates. 35 | You need to put the coordinates by yourself. 36 | 37 | 38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/enqueueLinks.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { Stack, TextField } from "@mui/material"; 3 | import { WarningText } from "../../atoms/texts"; 4 | import WarningIcon from "@mui/icons-material/Warning"; 5 | import InfoIcon from "@mui/icons-material/Info"; 6 | 7 | export const EnqueueLinksSettings = forwardRef((props, ref) => { 8 | const [settings, setSettings] = React.useState(''); 9 | useImperativeHandle(ref, () => ({ 10 | getSettings() { 11 | return settings; 12 | } 13 | })); 14 | 15 | return ( 16 | 17 | setSettings(e.target.value)} 23 | /> 24 | 25 | 26 | Reads elements targeted by the selector and stores their links in a queue. 27 | Those pages are then processed using the same workflow as the initial page 28 | (in parallel if the maxConcurrency parameter is greater than 1). 29 | 30 | 31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/index.ts: -------------------------------------------------------------------------------- 1 | import { ScrollSettings } from './scroll'; 2 | import { ScreenshotSettings } from "./screenshot"; 3 | import { ScrapeSettings } from "./scrape"; 4 | import { ScrapeSchemaSettings } from "./scrapeSchema"; 5 | import { ScriptSettings } from "./script"; 6 | import { EnqueueLinksSettings } from "./enqueueLinks"; 7 | import { ClickOnCoordinatesSettings } from "./clickOnCoordinates"; 8 | 9 | export { 10 | ScrollSettings, 11 | ScreenshotSettings, 12 | ScrapeSettings, 13 | ScrapeSchemaSettings, 14 | ScriptSettings, 15 | EnqueueLinksSettings, 16 | ClickOnCoordinatesSettings, 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/scrape.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { Stack, TextField } from "@mui/material"; 3 | import { WarningText } from '../../atoms/texts'; 4 | import InfoIcon from "@mui/icons-material/Info"; 5 | 6 | export const ScrapeSettings = forwardRef((props, ref) => { 7 | const [settings, setSettings] = React.useState(''); 8 | useImperativeHandle(ref, () => ({ 9 | getSettings() { 10 | return settings; 11 | } 12 | })); 13 | 14 | return ( 15 | 16 | setSettings(e.target.value)} 21 | /> 22 | 23 | 24 | The scrape function uses heuristic algorithm to automatically scrape only important data from the page. 25 | If a selector is used it will scrape and automatically parse all available 26 | data inside of the selected element(s). 27 | 28 | 29 | ); 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/scrapeSchema.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; 2 | import { WarningText } from "../../atoms/texts"; 3 | import InfoIcon from "@mui/icons-material/Info"; 4 | import { KeyValueForm } from "../KeyValueForm"; 5 | 6 | export const ScrapeSchemaSettings = forwardRef((props, ref) => { 7 | const keyValueFormRef = useRef<{getObject: () => object}>(null); 8 | 9 | useImperativeHandle(ref, () => ({ 10 | getSettings() { 11 | const settings = keyValueFormRef.current?.getObject() as Record 12 | return settings; 13 | } 14 | })); 15 | 16 | return ( 17 |
18 | 19 | 20 | The interpreter scrapes the data from a webpage into a "curated" table. 21 | 22 | 23 |
24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/screenshot.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { InputLabel, MenuItem, TextField, Select, FormControl } from "@mui/material"; 3 | import { ScreenshotSettings as Settings } from "../../../shared/types"; 4 | import styled from "styled-components"; 5 | import { SelectChangeEvent } from "@mui/material/Select/Select"; 6 | import { Dropdown } from "../../atoms/DropdownMui"; 7 | 8 | export const ScreenshotSettings = forwardRef((props, ref) => { 9 | const [settings, setSettings] = React.useState({ }); 10 | useImperativeHandle(ref, () => ({ 11 | getSettings() { 12 | return settings; 13 | } 14 | })); 15 | 16 | const handleInput = (event: React.ChangeEvent) => { 17 | const { id, value, type } = event.target; 18 | let parsedValue: any = value; 19 | if (type === "number") { 20 | parsedValue = parseInt(value); 21 | }; 22 | setSettings({ 23 | ...settings, 24 | [id]: parsedValue, 25 | }); 26 | }; 27 | 28 | const handleSelect = (event: SelectChangeEvent) => { 29 | const { name, value } = event.target; 30 | let parsedValue: any = value; 31 | if (value === "true" || value === "false") { 32 | parsedValue = value === "true"; 33 | }; 34 | setSettings({ 35 | ...settings, 36 | [name]: parsedValue, 37 | }); 38 | }; 39 | 40 | return ( 41 | 42 | 48 | jpeg 49 | png 50 | 51 | { settings.type === "jpeg" ? 52 | : null 60 | } 61 | 69 | 75 | disabled 76 | allow 77 | 78 | { settings.type === "png" ? 79 | 85 | true 86 | false 87 | 88 | : null 89 | } 90 | 96 | hide 97 | initial 98 | 99 | 105 | true 106 | false 107 | 108 | 114 | css 115 | device 116 | 117 | 118 | ); 119 | }); 120 | 121 | const SettingsWrapper = styled.div` 122 | margin-left: 15px; 123 | * { 124 | margin-bottom: 10px; 125 | } 126 | `; 127 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/script.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import Editor from 'react-simple-code-editor'; 3 | // @ts-ignore 4 | import { highlight, languages } from 'prismjs/components/prism-core'; 5 | import 'prismjs/components/prism-clike'; 6 | import 'prismjs/components/prism-javascript'; 7 | import 'prismjs/themes/prism.css'; 8 | import styled from "styled-components"; 9 | import InfoIcon from '@mui/icons-material/Info'; 10 | import { WarningText } from "../../atoms/texts"; 11 | 12 | export const ScriptSettings = forwardRef((props, ref) => { 13 | const [code, setCode] = React.useState(''); 14 | 15 | useImperativeHandle(ref, () => ({ 16 | getSettings() { 17 | return code; 18 | } 19 | })); 20 | 21 | return ( 22 | 23 | 24 | 25 | Allows to run an arbitrary asynchronous function evaluated at the server 26 | side accepting the current page instance argument. 27 | 28 | setCode(code)} 32 | highlight={code => highlight(code, languages.js)} 33 | padding={10} 34 | style={{ 35 | fontFamily: '"Fira code", "Fira Mono", monospace', 36 | fontSize: 12, 37 | background: '#f0f0f0', 38 | }} 39 | /> 40 | 41 | ); 42 | }); 43 | 44 | const EditorWrapper = styled.div` 45 | flex: 1; 46 | overflow: auto; 47 | /** hard-coded height */ 48 | height: 100%; 49 | width: 100%; 50 | `; 51 | 52 | const StyledEditor = styled(Editor)` 53 | white-space: pre; 54 | caret-color: #fff; 55 | min-width: 100%; 56 | min-height: 100%; 57 | float: left; 58 | & > textarea, 59 | & > pre { 60 | outline: none; 61 | white-space: pre !important; 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /src/components/molecules/action-settings/scroll.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle } from 'react'; 2 | import { TextField } from "@mui/material"; 3 | 4 | export const ScrollSettings = forwardRef((props, ref) => { 5 | const [settings, setSettings] = React.useState(0); 6 | useImperativeHandle(ref, () => ({ 7 | getSettings() { 8 | return settings; 9 | } 10 | })); 11 | 12 | return ( 13 | setSettings(parseInt(e.target.value))} 19 | /> 20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/organisms/BrowserContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import styled from "styled-components"; 3 | import BrowserNavBar from "../molecules/BrowserNavBar"; 4 | import { BrowserWindow } from "./BrowserWindow"; 5 | import { useBrowserDimensionsStore } from "../../context/browserDimensions"; 6 | import { BrowserTabs } from "../molecules/BrowserTabs"; 7 | import { useSocketStore } from "../../context/socket"; 8 | import { getCurrentTabs, getCurrentUrl, interpretCurrentRecording } from "../../api/recording"; 9 | 10 | export const BrowserContent = () => { 11 | const { width } = useBrowserDimensionsStore(); 12 | const { socket } = useSocketStore(); 13 | 14 | const [tabs, setTabs] = useState(['current']); 15 | const [tabIndex, setTabIndex] = React.useState(0); 16 | 17 | const handleChangeIndex = useCallback((index: number) => { 18 | setTabIndex(index); 19 | }, [tabIndex]) 20 | 21 | const handleCloseTab = useCallback((index: number) => { 22 | // the tab needs to be closed on the backend 23 | socket?.emit('closeTab', { 24 | index, 25 | isCurrent: tabIndex === index, 26 | }); 27 | // change the current index as current tab gets closed 28 | if (tabIndex === index) { 29 | if (tabs.length > index + 1) { 30 | handleChangeIndex(index); 31 | } else { 32 | handleChangeIndex(index - 1); 33 | } 34 | } else { 35 | handleChangeIndex(tabIndex - 1); 36 | } 37 | // update client tabs 38 | setTabs((prevState) => [ 39 | ...prevState.slice(0, index), 40 | ...prevState.slice(index + 1) 41 | ]) 42 | }, [tabs, socket, tabIndex]); 43 | 44 | const handleAddNewTab = useCallback(() => { 45 | // Adds new tab by pressing the plus button 46 | socket?.emit('addTab'); 47 | // Adds a new tab to the end of the tabs array and shifts focus 48 | setTabs((prevState) => [...prevState, 'new tab']); 49 | handleChangeIndex(tabs.length); 50 | }, [socket, tabs]); 51 | 52 | const handleNewTab = useCallback((tab: string) => { 53 | // Adds a new tab to the end of the tabs array and shifts focus 54 | setTabs((prevState) => [...prevState, tab]); 55 | // changes focus on the new tab - same happens in the remote browser 56 | handleChangeIndex(tabs.length); 57 | handleTabChange(tabs.length); 58 | }, [tabs]); 59 | 60 | const handleTabChange = useCallback((index: number) => { 61 | // page screencast and focus needs to be changed on backend 62 | socket?.emit('changeTab', index); 63 | }, [socket]); 64 | 65 | const handleUrlChanged = (url: string) => { 66 | const parsedUrl = new URL(url); 67 | if (parsedUrl.hostname) { 68 | const host = parsedUrl.hostname.match(/\b(?!www\.)[a-zA-Z0-9]+/g)?.join('.') 69 | if (host && host !== tabs[tabIndex]) { 70 | setTabs((prevState) => [ 71 | ...prevState.slice(0, tabIndex), 72 | host, 73 | ...prevState.slice(tabIndex + 1) 74 | ]) 75 | } 76 | } else { 77 | if (tabs[tabIndex] !== 'new tab') { 78 | setTabs((prevState) => [ 79 | ...prevState.slice(0, tabIndex), 80 | 'new tab', 81 | ...prevState.slice(tabIndex + 1) 82 | ]) 83 | } 84 | } 85 | 86 | }; 87 | 88 | const tabHasBeenClosedHandler = useCallback((index: number) => { 89 | handleCloseTab(index); 90 | }, [handleCloseTab]) 91 | 92 | useEffect(() => { 93 | if (socket) { 94 | socket.on('newTab', handleNewTab); 95 | socket.on('tabHasBeenClosed', tabHasBeenClosedHandler); 96 | } 97 | return () => { 98 | if (socket) { 99 | socket.off('newTab', handleNewTab); 100 | socket.off('tabHasBeenClosed', tabHasBeenClosedHandler); 101 | } 102 | } 103 | }, [socket, handleNewTab]) 104 | 105 | useEffect(() => { 106 | getCurrentTabs().then((response) => { 107 | if (response) { 108 | setTabs(response); 109 | } 110 | }).catch((error) => { 111 | console.log("Fetching current url failed"); 112 | }) 113 | }, []) 114 | 115 | return ( 116 | 117 | 125 | 129 | 130 | 131 | ); 132 | } 133 | 134 | const BrowserContentWrapper = styled.div` 135 | grid-area: browser; 136 | `; 137 | -------------------------------------------------------------------------------- /src/components/organisms/BrowserWindow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { useSocketStore } from '../../context/socket'; 3 | import Canvas from "../atoms/canvas"; 4 | import { useBrowserDimensionsStore } from "../../context/browserDimensions"; 5 | import { Highlighter } from "../atoms/Highlighter"; 6 | 7 | export const BrowserWindow = () => { 8 | 9 | const [canvasRef, setCanvasReference] = useState | undefined>(undefined); 10 | const [screenShot, setScreenShot] = useState(""); 11 | const [highlighterData, setHighlighterData] = useState<{rect: DOMRect, selector: string} | null>(null); 12 | 13 | const { socket } = useSocketStore(); 14 | const { width, height } = useBrowserDimensionsStore(); 15 | 16 | const onMouseMove = (e: MouseEvent) =>{ 17 | if (canvasRef && canvasRef.current && highlighterData) { 18 | const canvasRect = canvasRef.current.getBoundingClientRect(); 19 | // mousemove outside the browser window 20 | if ( 21 | e.pageX < canvasRect.left 22 | || e.pageX > canvasRect.right 23 | || e.pageY < canvasRect.top 24 | || e.pageY > canvasRect.bottom 25 | ){ 26 | setHighlighterData(null); 27 | } 28 | } 29 | }; 30 | 31 | const screencastHandler = useCallback((data: string) => { 32 | setScreenShot(data); 33 | }, [screenShot]); 34 | 35 | useEffect(() => { 36 | if (socket) { 37 | socket.on("screencast", screencastHandler); 38 | } 39 | if (canvasRef?.current) { 40 | drawImage(screenShot, canvasRef.current); 41 | } else { 42 | console.log('Canvas is not initialized'); 43 | } 44 | return () => { 45 | socket?.off("screencast", screencastHandler); 46 | } 47 | 48 | }, [screenShot, canvasRef, socket, screencastHandler]); 49 | 50 | 51 | const highlighterHandler = useCallback((data: {rect: DOMRect, selector: string}) => { 52 | setHighlighterData(data); 53 | }, [highlighterData]) 54 | 55 | useEffect(() => { 56 | document.addEventListener('mousemove', onMouseMove, false); 57 | if (socket) { 58 | socket.on("highlighter", highlighterHandler); 59 | } 60 | //cleaning function 61 | return () => { 62 | document.removeEventListener('mousemove', onMouseMove); 63 | socket?.off("highlighter", highlighterHandler); 64 | }; 65 | }, [socket, onMouseMove]); 66 | 67 | return ( 68 | <> 69 | {(highlighterData?.rect != null && highlighterData?.rect.top != null) && canvasRef?.current ? 70 | 77 | : null } 78 | 83 | 84 | ); 85 | }; 86 | 87 | const drawImage = (image: string, canvas: HTMLCanvasElement) :void => { 88 | 89 | const ctx = canvas.getContext('2d'); 90 | 91 | const img = new Image(); 92 | 93 | img.src = image; 94 | img.onload = () => { 95 | URL.revokeObjectURL(img.src); 96 | //ctx?.clearRect(0, 0, canvas?.width || 0, VIEWPORT_H || 0); 97 | ctx?.drawImage(img, 0, 0, canvas.width , canvas.height); 98 | }; 99 | 100 | }; 101 | -------------------------------------------------------------------------------- /src/components/organisms/LeftSidePanel.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Paper, Tab, Tabs } from "@mui/material"; 2 | import React, { useCallback, useEffect, useState } from "react"; 3 | import { getActiveWorkflow, getParamsOfActiveWorkflow } from "../../api/workflow"; 4 | import { useSocketStore } from '../../context/socket'; 5 | import { WhereWhatPair, WorkflowFile } from "@wbr-project/wbr-interpret"; 6 | import { SidePanelHeader } from "../molecules/SidePanelHeader"; 7 | import { emptyWorkflow } from "../../shared/constants"; 8 | import { LeftSidePanelContent } from "../molecules/LeftSidePanelContent"; 9 | import { useBrowserDimensionsStore } from "../../context/browserDimensions"; 10 | import { useGlobalInfoStore } from "../../context/globalInfo"; 11 | import { TabContext, TabPanel } from "@mui/lab"; 12 | import { LeftSidePanelSettings } from "../molecules/LeftSidePanelSettings"; 13 | import { RunSettings } from "../molecules/RunSettings"; 14 | 15 | const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { 16 | getActiveWorkflow(id).then( 17 | (response ) => { 18 | if (response){ 19 | callback(response); 20 | } else { 21 | throw new Error("No workflow found"); 22 | } 23 | } 24 | ).catch((error) => {console.log(error.message)}) 25 | }; 26 | 27 | interface LeftSidePanelProps { 28 | sidePanelRef: HTMLDivElement | null; 29 | alreadyHasScrollbar: boolean; 30 | recordingName: string; 31 | handleSelectPairForEdit: (pair:WhereWhatPair, index:number) => void; 32 | } 33 | 34 | export const LeftSidePanel = ( 35 | { sidePanelRef, alreadyHasScrollbar, recordingName, handleSelectPairForEdit }: LeftSidePanelProps) => { 36 | 37 | const [workflow, setWorkflow] = useState(emptyWorkflow); 38 | const [hasScrollbar, setHasScrollbar] = useState(alreadyHasScrollbar); 39 | const [tab, setTab] = useState('recording'); 40 | const [params, setParams] = useState([]); 41 | const [settings, setSettings] = React.useState({ 42 | maxConcurrency: 1, 43 | maxRepeats: 1, 44 | debug: false, 45 | }); 46 | 47 | const { id, socket } = useSocketStore(); 48 | const { setWidth, width } = useBrowserDimensionsStore(); 49 | const { setRecordingLength } = useGlobalInfoStore(); 50 | 51 | const workflowHandler = useCallback((data: WorkflowFile) => { 52 | setWorkflow(data); 53 | setRecordingLength(data.workflow.length); 54 | }, [workflow]) 55 | 56 | useEffect(() => { 57 | // fetch the workflow every time the id changes 58 | if (id) { 59 | fetchWorkflow(id, workflowHandler); 60 | } 61 | // fetch workflow in 15min intervals 62 | let interval = setInterval(() =>{ 63 | if (id) { 64 | fetchWorkflow(id, workflowHandler); 65 | }}, (1000 * 60 * 15)); 66 | return () => clearInterval(interval) 67 | }, [id]); 68 | 69 | useEffect(() => { 70 | if (socket) { 71 | socket.on("workflow", workflowHandler); 72 | } 73 | 74 | if (sidePanelRef) { 75 | const workflowListHeight = sidePanelRef.clientHeight; 76 | const innerHeightWithoutNavbar = window.innerHeight - 70; 77 | if (innerHeightWithoutNavbar <= workflowListHeight) { 78 | if (!hasScrollbar) { 79 | setWidth(width - 10); 80 | setHasScrollbar(true); 81 | } 82 | } else { 83 | if (hasScrollbar && !alreadyHasScrollbar) { 84 | setWidth(width + 10); 85 | setHasScrollbar(false); 86 | } 87 | } 88 | } 89 | 90 | return () => { 91 | socket?.off('workflow', workflowHandler); 92 | } 93 | }, [socket, workflowHandler]); 94 | 95 | return ( 96 | 107 | 108 | 109 | setTab(newTab)}> 110 | 111 | { 112 | getParamsOfActiveWorkflow(id).then((response) => { 113 | if (response) { 114 | setParams(response); 115 | } 116 | }) 117 | }}/> 118 | 119 | 120 | 126 | 127 | 128 | 130 | 131 | 132 | 133 | ); 134 | 135 | }; 136 | -------------------------------------------------------------------------------- /src/components/organisms/MainMenu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Tabs from '@mui/material/Tabs'; 3 | import Tab from '@mui/material/Tab'; 4 | import Box from '@mui/material/Box'; 5 | import { Paper } from "@mui/material"; 6 | import styled from "styled-components"; 7 | 8 | interface MainMenuProps { 9 | value: string; 10 | handleChangeContent: (newValue: string) => void; 11 | } 12 | 13 | export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => { 14 | 15 | const handleChange = (event: React.SyntheticEvent, newValue: string) => { 16 | handleChangeContent(newValue); 17 | }; 18 | 19 | return ( 20 | 28 | 32 | 39 | 43 | 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/organisms/Recordings.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { RecordingsTable } from "../molecules/RecordingsTable"; 3 | import { Grid } from "@mui/material"; 4 | import { RunSettings, RunSettingsModal } from "../molecules/RunSettings"; 5 | 6 | interface RecordingsProps { 7 | handleEditRecording: (fileName: string) => void; 8 | handleRunRecording: (settings: RunSettings) => void; 9 | setFileName: (fileName: string) => void; 10 | 11 | } 12 | 13 | export const Recordings = ({ handleEditRecording, handleRunRecording, setFileName }: RecordingsProps) => { 14 | const [runSettingsAreOpen, setRunSettingsAreOpen] = useState(false); 15 | const [params, setParams] = useState([]); 16 | 17 | const handleSettingsAndRun = (fileName: string, params: string[]) => { 18 | if (params.length === 0) { 19 | setRunSettingsAreOpen(true); 20 | setFileName(fileName); 21 | } else { 22 | setParams(params); 23 | setRunSettingsAreOpen(true); 24 | setFileName(fileName); 25 | } 26 | } 27 | 28 | const handleClose = () => { 29 | setParams([]); 30 | setRunSettingsAreOpen(false); 31 | setFileName(''); 32 | } 33 | 34 | return ( 35 | 36 | handleRunRecording(settings) } 39 | isTask={params.length !== 0} 40 | params={params} 41 | /> 42 | 43 | 44 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/components/organisms/RightSidePanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Button, MenuItem, Paper, Stack, Tabs, Tab } from "@mui/material"; 3 | import { Dropdown as MuiDropdown } from '../atoms/DropdownMui'; 4 | import styled from "styled-components"; 5 | import { ActionSettings } from "../molecules/ActionSettings"; 6 | import { SelectChangeEvent } from "@mui/material/Select/Select"; 7 | import { SimpleBox } from "../atoms/Box"; 8 | import Typography from "@mui/material/Typography"; 9 | import { useGlobalInfoStore } from "../../context/globalInfo"; 10 | import { PairDetail } from "../molecules/PairDetail"; 11 | import { PairForEdit } from "../../pages/RecordingPage"; 12 | 13 | interface RightSidePanelProps { 14 | pairForEdit: PairForEdit; 15 | changeBrowserDimensions: () => void; 16 | } 17 | 18 | export const RightSidePanel = ({pairForEdit, changeBrowserDimensions}: RightSidePanelProps) => { 19 | 20 | const [content, setContent] = useState('action'); 21 | const [action, setAction] = React.useState(''); 22 | const [isSettingsDisplayed, setIsSettingsDisplayed] = React.useState(false); 23 | 24 | const { lastAction } = useGlobalInfoStore(); 25 | 26 | const handleChange = (event: React.SyntheticEvent, newValue: string) => { 27 | setContent(newValue); 28 | }; 29 | 30 | const handleActionSelect = (event: SelectChangeEvent) => { 31 | const { value } = event.target; 32 | setAction(value); 33 | setIsSettingsDisplayed(true); 34 | }; 35 | 36 | useEffect(() => { 37 | if (content !== 'detail' && pairForEdit.pair !== null) { 38 | setContent('detail'); 39 | } 40 | }, [pairForEdit]) 41 | 42 | return ( 43 | 51 | 54 | 55 | 56 | Last action: 57 | {` ${lastAction}`} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | {content === 'action' ? ( 67 | 68 | Type of action: 69 | 70 | 75 | click on coordinates 76 | enqueueLinks 77 | scrape 78 | scrapeSchema 79 | screenshot 80 | script 81 | scroll 82 | 83 | 84 | 85 | {isSettingsDisplayed && 86 | 87 | } 88 | 89 | ) 90 | : 91 | } 92 | 93 | ); 94 | }; 95 | 96 | const ActionTypeWrapper = styled.div` 97 | display: flex; 98 | flex-direction: column; 99 | align-items: center; 100 | justify-content: center; 101 | margin-top: 20px; 102 | `; 103 | 104 | export const ActionDescription = styled.p` 105 | margin-left: 15px; 106 | `; 107 | -------------------------------------------------------------------------------- /src/components/organisms/Runs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Grid } from "@mui/material"; 3 | import { RunsTable } from "../molecules/RunsTable"; 4 | 5 | interface RunsProps { 6 | currentInterpretationLog: string; 7 | abortRunHandler: () => void; 8 | runId: string; 9 | runningRecordingName: string; 10 | } 11 | 12 | export const Runs = ( 13 | { currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsProps) => { 14 | 15 | return ( 16 | 17 | 18 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/constants/const.ts: -------------------------------------------------------------------------------- 1 | export const VIEWPORT_W = 1280; 2 | export const VIEWPORT_H = 720; 3 | 4 | export const ONE_PERCENT_OF_VIEWPORT_W = VIEWPORT_W / 100; 5 | export const ONE_PERCENT_OF_VIEWPORT_H = VIEWPORT_H / 100; 6 | -------------------------------------------------------------------------------- /src/context/browserDimensions.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext, useState } from "react"; 2 | 3 | interface BrowserDimensions { 4 | width: number; 5 | height: number; 6 | setWidth: (newWidth: number) => void; 7 | }; 8 | 9 | class BrowserDimensionsStore implements Partial{ 10 | width: number = 936; 11 | height: number = Math.round(this.width / 1.6); 12 | }; 13 | 14 | const browserDimensionsStore = new BrowserDimensionsStore(); 15 | const browserDimensionsContext = createContext(browserDimensionsStore as BrowserDimensions); 16 | 17 | export const useBrowserDimensionsStore = () => useContext(browserDimensionsContext); 18 | 19 | export const BrowserDimensionsProvider = ({ children }: { children: JSX.Element }) => { 20 | const [width, setWidth] = useState(browserDimensionsStore.width); 21 | const [height, setHeight] = useState(browserDimensionsStore.height); 22 | 23 | const setNewWidth = useCallback((newWidth: number) => { 24 | setWidth(newWidth); 25 | setHeight(Math.round(newWidth / 1.6)); 26 | }, [setWidth, setHeight]); 27 | 28 | return ( 29 | 36 | {children} 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/context/globalInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from "react"; 2 | import { AlertSnackbarProps } from "../components/atoms/AlertSnackbar"; 3 | 4 | 5 | interface GlobalInfo { 6 | browserId: string | null; 7 | setBrowserId: (newId: string | null) => void; 8 | lastAction: string; 9 | setLastAction: (action: string ) => void; 10 | notification: AlertSnackbarProps; 11 | notify: (severity: 'error' | 'warning' | 'info' | 'success', message: string) => void; 12 | closeNotify: () => void; 13 | recordings: string[]; 14 | setRecordings: (recordings: string[]) => void; 15 | rerenderRuns: boolean; 16 | setRerenderRuns: (rerenderRuns: boolean) => void; 17 | recordingLength: number; 18 | setRecordingLength: (recordingLength: number) => void; 19 | }; 20 | 21 | class GlobalInfoStore implements Partial{ 22 | browserId = null; 23 | lastAction = ''; 24 | recordingLength = 0; 25 | notification: AlertSnackbarProps = { 26 | severity: 'info', 27 | message: '', 28 | isOpen: false, 29 | }; 30 | recordings: string[] = []; 31 | rerenderRuns = false; 32 | }; 33 | 34 | const globalInfoStore = new GlobalInfoStore(); 35 | const globalInfoContext = createContext(globalInfoStore as GlobalInfo); 36 | 37 | export const useGlobalInfoStore = () => useContext(globalInfoContext); 38 | 39 | export const GlobalInfoProvider = ({ children }: { children: JSX.Element }) => { 40 | const [browserId, setBrowserId] = useState(globalInfoStore.browserId); 41 | const [lastAction, setLastAction] = useState(globalInfoStore.lastAction); 42 | const [notification, setNotification] = useState(globalInfoStore.notification); 43 | const [recordings, setRecordings] = useState(globalInfoStore.recordings); 44 | const [rerenderRuns, setRerenderRuns] = useState(globalInfoStore.rerenderRuns); 45 | const [recordingLength, setRecordingLength] = useState(globalInfoStore.recordingLength); 46 | 47 | const notify = (severity: 'error' | 'warning' | 'info' | 'success', message: string) => { 48 | setNotification({severity, message, isOpen: true}); 49 | } 50 | 51 | const closeNotify = () => { 52 | setNotification( globalInfoStore.notification); 53 | } 54 | 55 | const setBrowserIdWithValidation = (browserId: string | null) => { 56 | setBrowserId(browserId); 57 | if (!browserId) { 58 | setRecordingLength(0); 59 | } 60 | } 61 | 62 | return ( 63 | 80 | {children} 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/context/socket.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; 2 | import { io, Socket } from 'socket.io-client'; 3 | 4 | const SERVER_ENDPOINT = 'http://localhost:8080'; 5 | 6 | interface SocketState { 7 | socket: Socket | null; 8 | id: string; 9 | setId: (id: string) => void; 10 | }; 11 | 12 | class SocketStore implements Partial{ 13 | socket = null; 14 | id = ''; 15 | }; 16 | 17 | const socketStore = new SocketStore(); 18 | const socketStoreContext = createContext(socketStore as SocketState); 19 | 20 | export const useSocketStore = () => useContext(socketStoreContext); 21 | 22 | export const SocketProvider = ({ children }: { children: JSX.Element }) => { 23 | const [socket, setSocket] = useState(socketStore.socket); 24 | const [id, setActiveId] = useState(socketStore.id); 25 | 26 | const setId = useCallback((id: string) => { 27 | // the socket client connection is recomputed whenever id changes -> the new browser has been initialized 28 | const socket = 29 | io(`${SERVER_ENDPOINT}/${id}`, { 30 | transports: ["websocket"], 31 | rejectUnauthorized: false 32 | }); 33 | 34 | socket.on('connect', () => console.log('connected to socket')); 35 | socket.on("connect_error", (err) => console.log(`connect_error due to ${err.message}`)); 36 | 37 | setSocket(socket); 38 | setActiveId(id); 39 | }, [setSocket]); 40 | 41 | return ( 42 | 49 | {children} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/helpers/inputHelpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ONE_PERCENT_OF_VIEWPORT_H, 3 | ONE_PERCENT_OF_VIEWPORT_W, 4 | VIEWPORT_W, 5 | VIEWPORT_H, 6 | } from "../constants/const"; 7 | import { Coordinates } from '../components/atoms/canvas'; 8 | 9 | export const throttle = (callback: any, limit: number) => { 10 | let wait = false; 11 | return (...args: any[]) => { 12 | if (!wait) { 13 | callback(...args); 14 | wait = true; 15 | setTimeout(function () { 16 | wait = false; 17 | }, limit); 18 | } 19 | } 20 | } 21 | 22 | export const getMappedCoordinates = ( 23 | event: MouseEvent, 24 | canvas: HTMLCanvasElement | null, 25 | browserWidth: number, 26 | browserHeight: number, 27 | ): Coordinates => { 28 | const clientCoordinates = getCoordinates(event, canvas); 29 | const mappedX = mapPixelFromSmallerToLarger( 30 | browserWidth / 100, 31 | ONE_PERCENT_OF_VIEWPORT_W, 32 | clientCoordinates.x, 33 | ); 34 | const mappedY = mapPixelFromSmallerToLarger( 35 | browserHeight / 100, 36 | ONE_PERCENT_OF_VIEWPORT_H, 37 | clientCoordinates.y, 38 | ); 39 | 40 | return { 41 | x: mappedX, 42 | y: mappedY 43 | }; 44 | }; 45 | 46 | const getCoordinates = (event: MouseEvent, canvas: HTMLCanvasElement | null): Coordinates => { 47 | if (!canvas) { 48 | return { x: 0, y: 0}; 49 | } 50 | return { 51 | x: event.pageX - canvas.offsetLeft, 52 | y: event.pageY - canvas.offsetTop 53 | }; 54 | }; 55 | 56 | export const mapRect = ( 57 | rect: DOMRect, 58 | browserWidth: number, 59 | browserHeight: number, 60 | ) => { 61 | const mappedX = mapPixelFromLargerToSmaller( 62 | browserWidth / 100, 63 | ONE_PERCENT_OF_VIEWPORT_W, 64 | rect.x, 65 | ); 66 | const mappedLeft = mapPixelFromLargerToSmaller( 67 | browserWidth / 100, 68 | ONE_PERCENT_OF_VIEWPORT_W, 69 | rect.left, 70 | ); 71 | const mappedRight = mapPixelFromLargerToSmaller( 72 | browserWidth / 100, 73 | ONE_PERCENT_OF_VIEWPORT_W, 74 | rect.right, 75 | ); 76 | const mappedWidth = mapPixelFromLargerToSmaller( 77 | browserWidth / 100, 78 | ONE_PERCENT_OF_VIEWPORT_W, 79 | rect.width, 80 | ); 81 | const mappedY = mapPixelFromLargerToSmaller( 82 | browserHeight / 100, 83 | ONE_PERCENT_OF_VIEWPORT_H, 84 | rect.y, 85 | ); 86 | const mappedTop = mapPixelFromLargerToSmaller( 87 | browserHeight / 100, 88 | ONE_PERCENT_OF_VIEWPORT_H, 89 | rect.top, 90 | ); 91 | const mappedBottom = mapPixelFromLargerToSmaller( 92 | browserHeight / 100, 93 | ONE_PERCENT_OF_VIEWPORT_H, 94 | rect.bottom, 95 | ); 96 | const mappedHeight = mapPixelFromLargerToSmaller( 97 | browserHeight / 100, 98 | ONE_PERCENT_OF_VIEWPORT_H, 99 | rect.height, 100 | ); 101 | 102 | return { 103 | x: mappedX, 104 | y: mappedY, 105 | width: mappedWidth, 106 | height: mappedHeight, 107 | top: mappedTop, 108 | right: mappedRight, 109 | bottom: mappedBottom, 110 | left: mappedLeft, 111 | }; 112 | }; 113 | 114 | const mapPixelFromSmallerToLarger = ( 115 | onePercentOfSmallerScreen: number, 116 | onePercentOfLargerScreen: number, 117 | pixel: number 118 | ) : number => { 119 | const xPercentOfScreen = pixel / onePercentOfSmallerScreen; 120 | return Math.round(xPercentOfScreen * onePercentOfLargerScreen); 121 | }; 122 | 123 | const mapPixelFromLargerToSmaller = ( 124 | onePercentOfSmallerScreen: number, 125 | onePercentOfLargerScreen: number, 126 | pixel: number 127 | ) : number => { 128 | const xPercentOfScreen = pixel / onePercentOfLargerScreen; 129 | return Math.round(xPercentOfScreen * onePercentOfSmallerScreen); 130 | }; 131 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | html { 16 | overflow-y:scroll; 17 | } 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot( 7 | document.getElementById('root') as HTMLElement 8 | ); 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | 15 | -------------------------------------------------------------------------------- /src/pages/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect } from 'react'; 2 | import { MainMenu } from "../components/organisms/MainMenu"; 3 | import { Grid, Stack } from "@mui/material"; 4 | import { Recordings } from "../components/organisms/Recordings"; 5 | import { Runs } from "../components/organisms/Runs"; 6 | import { useGlobalInfoStore } from "../context/globalInfo"; 7 | import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort } from "../api/storage"; 8 | import { io, Socket } from "socket.io-client"; 9 | import { stopRecording } from "../api/recording"; 10 | import { RunSettings } from "../components/molecules/RunSettings"; 11 | 12 | interface MainPageProps { 13 | handleEditRecording: (fileName: string) => void; 14 | } 15 | 16 | export interface CreateRunResponse { 17 | browserId: string; 18 | runId: string; 19 | } 20 | 21 | export const MainPage = ({ handleEditRecording }: MainPageProps) => { 22 | 23 | const [content, setContent] = React.useState('recordings'); 24 | const [sockets, setSockets] = React.useState([]); 25 | const [runningRecordingName, setRunningRecordingName] = React.useState(''); 26 | const [currentInterpretationLog, setCurrentInterpretationLog] = React.useState(''); 27 | const [ids, setIds] = React.useState({ 28 | browserId: '', 29 | runId: '' 30 | }); 31 | 32 | let aborted = false; 33 | 34 | const { notify, setRerenderRuns } = useGlobalInfoStore(); 35 | 36 | const abortRunHandler = (runId: string) => { 37 | aborted = true; 38 | notifyAboutAbort(runningRecordingName, runId).then(async (response) => { 39 | if (response) { 40 | notify('success', `Interpretation of ${runningRecordingName} aborted successfully`); 41 | await stopRecording(ids.browserId); 42 | } else { 43 | notify('error', `Failed to abort the interpretation ${runningRecordingName} recording`); 44 | } 45 | }) 46 | } 47 | 48 | const setFileName = (fileName: string) => { 49 | setRunningRecordingName(fileName); 50 | } 51 | 52 | const readyForRunHandler = useCallback( (browserId: string, runId: string) => { 53 | interpretStoredRecording(runningRecordingName, runId).then( async (interpretation: boolean) => { 54 | if (!aborted) { 55 | if (interpretation) { 56 | notify('success', `Interpretation of ${runningRecordingName} succeeded`); 57 | } else { 58 | notify('success', `Failed to interpret ${runningRecordingName} recording`); 59 | // destroy the created browser 60 | await stopRecording(browserId); 61 | } 62 | } 63 | setRunningRecordingName(''); 64 | setCurrentInterpretationLog(''); 65 | setRerenderRuns(true); 66 | }) 67 | }, [runningRecordingName, aborted, currentInterpretationLog, notify, setRerenderRuns]); 68 | 69 | const debugMessageHandler = useCallback((msg: string) => { 70 | setCurrentInterpretationLog((prevState) => 71 | prevState + '\n' + `[${new Date().toLocaleString()}] ` + msg); 72 | }, [currentInterpretationLog]) 73 | 74 | const handleRunRecording = useCallback((settings: RunSettings) => { 75 | createRunForStoredRecording(runningRecordingName, settings).then(({browserId, runId}: CreateRunResponse) => { 76 | setIds({browserId, runId}); 77 | const socket = 78 | io(`http://localhost:8080/${browserId}`, { 79 | transports: ["websocket"], 80 | rejectUnauthorized: false 81 | }); 82 | setSockets(sockets => [...sockets, socket]); 83 | socket.on('ready-for-run', () => readyForRunHandler(browserId, runId)); 84 | socket.on('debugMessage', debugMessageHandler); 85 | setContent('runs'); 86 | if (browserId) { 87 | notify('info', `Running recording: ${runningRecordingName}`); 88 | } else { 89 | notify('error', `Failed to run recording: ${runningRecordingName}`); 90 | } 91 | }); 92 | return (socket: Socket, browserId: string, runId: string) => { 93 | socket.off('ready-for-run', () => readyForRunHandler(browserId, runId)); 94 | socket.off('debugMessage', debugMessageHandler); 95 | } 96 | }, [runningRecordingName, sockets, ids, readyForRunHandler, debugMessageHandler]) 97 | 98 | const DisplayContent = () => { 99 | switch (content) { 100 | case 'recordings': 101 | return ; 106 | case 'runs': 107 | return abortRunHandler(ids.runId)} 110 | runId={ids.runId} 111 | runningRecordingName={runningRecordingName} 112 | />; 113 | default: 114 | return null; 115 | } 116 | } 117 | 118 | return ( 119 | 120 | 121 | { DisplayContent() } 122 | 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/pages/PageWrappper.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { NavBar } from "../components/molecules/NavBar"; 3 | import { SocketProvider } from "../context/socket"; 4 | import { BrowserDimensionsProvider } from "../context/browserDimensions"; 5 | import { RecordingPage } from "./RecordingPage"; 6 | import { MainPage } from "./MainPage"; 7 | import { useGlobalInfoStore } from "../context/globalInfo"; 8 | import { getActiveBrowserId } from "../api/recording"; 9 | import { AlertSnackbar } from "../components/atoms/AlertSnackbar"; 10 | import { InterpretationLog } from "../components/molecules/InterpretationLog"; 11 | 12 | 13 | export const PageWrapper = () => { 14 | 15 | const [recordingName, setRecordingName] = useState(''); 16 | const [open, setOpen] = useState(false); 17 | 18 | const { browserId, setBrowserId, notification } = useGlobalInfoStore(); 19 | 20 | const handleNewRecording = () => { 21 | setBrowserId('new-recording'); 22 | setRecordingName(''); 23 | } 24 | 25 | const handleEditRecording = (fileName: string) => { 26 | setRecordingName(fileName); 27 | setBrowserId('new-recording'); 28 | } 29 | 30 | const isNotification = (): boolean=> { 31 | if (notification.isOpen && !open){ 32 | setOpen(true); 33 | } 34 | return notification.isOpen; 35 | } 36 | 37 | useEffect(() => { 38 | const isRecordingInProgress = async() => { 39 | const id = await getActiveBrowserId(); 40 | if (id) { 41 | setBrowserId(id); 42 | } 43 | } 44 | isRecordingInProgress(); 45 | }, []); 46 | 47 | return ( 48 |
49 | 50 | 51 | 52 | {browserId 53 | ? ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | : 64 | } 65 | 66 | 67 | { isNotification() ? 68 | 71 | : null 72 | } 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/pages/RecordingPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { Grid } from '@mui/material'; 3 | import { BrowserContent } from "../components/organisms/BrowserContent"; 4 | import { startRecording, getActiveBrowserId } from "../api/recording"; 5 | import { LeftSidePanel } from "../components/organisms/LeftSidePanel"; 6 | import { RightSidePanel } from "../components/organisms/RightSidePanel"; 7 | import { Loader } from "../components/atoms/Loader"; 8 | import { useSocketStore } from "../context/socket"; 9 | import { useBrowserDimensionsStore } from "../context/browserDimensions"; 10 | import { useGlobalInfoStore } from "../context/globalInfo"; 11 | import { editRecordingFromStorage } from "../api/storage"; 12 | import { WhereWhatPair } from "@wbr-project/wbr-interpret"; 13 | 14 | interface RecordingPageProps { 15 | recordingName?: string; 16 | } 17 | 18 | export interface PairForEdit { 19 | pair: WhereWhatPair | null, 20 | index: number, 21 | } 22 | 23 | export const RecordingPage = ({ recordingName }: RecordingPageProps) => { 24 | 25 | const [isLoaded, setIsLoaded] = React.useState(false); 26 | const [hasScrollbar, setHasScrollbar] = React.useState(false); 27 | const [pairForEdit, setPairForEdit] = useState({ 28 | pair: null, 29 | index: 0, 30 | }); 31 | 32 | const browserContentRef = React.useRef(null); 33 | const workflowListRef = React.useRef(null); 34 | 35 | const { setId, socket } = useSocketStore(); 36 | const { setWidth } = useBrowserDimensionsStore(); 37 | const { browserId, setBrowserId } = useGlobalInfoStore(); 38 | 39 | const handleSelectPairForEdit = (pair: WhereWhatPair, index: number) => { 40 | setPairForEdit({ 41 | pair, 42 | index, 43 | }); 44 | }; 45 | 46 | //resize browser content when loaded event is fired 47 | useEffect(() => changeBrowserDimensions(), [isLoaded]) 48 | 49 | useEffect(() => { 50 | let isCancelled = false; 51 | const handleRecording = async () => { 52 | const id = await getActiveBrowserId(); 53 | if (!isCancelled) { 54 | if (id) { 55 | setId(id); 56 | setBrowserId(id); 57 | setIsLoaded(true); 58 | } else { 59 | const newId = await startRecording() 60 | setId(newId); 61 | setBrowserId(newId); 62 | } 63 | } 64 | }; 65 | 66 | handleRecording(); 67 | 68 | return () => { 69 | isCancelled = true; 70 | } 71 | }, [setId]); 72 | 73 | const changeBrowserDimensions = useCallback(() => { 74 | if (browserContentRef.current) { 75 | const currentWidth = Math.floor(browserContentRef.current.getBoundingClientRect().width); 76 | const innerHeightWithoutNavBar = window.innerHeight - 54.5; 77 | if ( innerHeightWithoutNavBar <= (currentWidth / 1.6)) { 78 | setWidth(currentWidth - 10); 79 | setHasScrollbar(true); 80 | } else { 81 | setWidth(currentWidth); 82 | } 83 | socket?.emit("rerender"); 84 | } 85 | }, [socket]); 86 | 87 | const handleLoaded = useCallback(() => { 88 | if (recordingName && browserId) { 89 | editRecordingFromStorage(browserId, recordingName).then(() => setIsLoaded(true)); 90 | } else { 91 | if (browserId === 'new-recording') { 92 | socket?.emit('new-recording'); 93 | } 94 | setIsLoaded(true); 95 | } 96 | }, [socket, browserId, recordingName, isLoaded]) 97 | 98 | useEffect(() => { 99 | socket?.on('loaded', handleLoaded); 100 | return () => { 101 | socket?.off('loaded', handleLoaded) 102 | } 103 | }, [socket, handleLoaded]); 104 | 105 | return ( 106 |
107 | {isLoaded ? 108 | 109 | 110 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | : } 125 |
126 | ); 127 | }; 128 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowFile } from "@wbr-project/wbr-interpret"; 2 | 3 | export const emptyWorkflow: WorkflowFile = { workflow: [] }; 4 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowFile } from "@wbr-project/wbr-interpret"; 2 | import { Locator } from "playwright"; 3 | 4 | export type Workflow = WorkflowFile["workflow"]; 5 | 6 | export interface ScreenshotSettings { 7 | animations?: "disabled" | "allow"; 8 | caret?: "hide" | "initial"; 9 | clip?: { 10 | x: number; 11 | y: number; 12 | width: number; 13 | height: number; 14 | }; 15 | fullPage?: boolean; 16 | mask?: Locator[]; 17 | omitBackground?: boolean; 18 | // is this still needed? - @wbr-project/wbr-interpret outputs to a binary output 19 | path?: string; 20 | quality?: number; 21 | scale?: "css" | "device"; 22 | timeout?: number; 23 | type?: "jpeg" | "png"; 24 | }; 25 | 26 | export declare type CustomActions = 'scrape' | 'scrapeSchema' | 'scroll' | 'screenshot' | 'script' | 'enqueueLinks' | 'flag'; 27 | -------------------------------------------------------------------------------- /thesis.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sauermar/web-browser-recorder/0590be482fb9686acb7a070f2d9b2b5fb26bb12f/thesis.pdf -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "target": "esnext", 17 | "module": "commonjs", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx", 23 | "outDir": "./build" 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src", "./server/src"], 4 | "sort": ["source-order"], 5 | "categorizeByGroup": false, 6 | "tsconfig": "./tsconfig.json" 7 | } 8 | --------------------------------------------------------------------------------