├── .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 |
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 |
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 |
108 |
109 |
110 |
111 |
112 |
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 |
35 |
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 |
96 |
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 |
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 |
80 |
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 |
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 |
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 |
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 |
108 |
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 |
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 |
49 |
50 |
51 | { settings.type === "jpeg" ?
52 | : null
60 | }
61 |
69 |
75 |
76 |
77 |
78 | { settings.type === "png" ?
79 |
85 |
86 |
87 |
88 | : null
89 | }
90 |
96 |
97 |
98 |
99 |
105 |
106 |
107 |
108 |
114 |
115 |
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 |
76 |
77 |
78 |
79 |
80 |
81 |
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 |
--------------------------------------------------------------------------------