diff --git a/bci/browser/interaction/interaction.py b/bci/browser/interaction/interaction.py index 14fb8d1b..a277db20 100644 --- a/bci/browser/interaction/interaction.py +++ b/bci/browser/interaction/interaction.py @@ -1,9 +1,11 @@ import logging from inspect import signature +from urllib.parse import quote_plus from bci.browser.configuration.browser import Browser as BrowserConfig from bci.browser.interaction.simulation import Simulation from bci.evaluations.logic import TestParameters +from bci.browser.interaction.simulation_exception import SimulationException logger = logging.getLogger(__name__) @@ -26,32 +28,38 @@ def execute(self) -> None: simulation.navigate('https://a.test/report/?bughog_sanity_check=OK') def _interpret(self, simulation: Simulation) -> bool: - for statement in self.script: - if statement.strip() == '' or statement[0] == '#': - continue + try: + for statement in self.script: + if statement.strip() == '' or statement[0] == '#': + continue - cmd, *args = statement.split() - method_name = cmd.lower() + cmd, *args = statement.split() + method_name = cmd.lower() - if method_name not in Simulation.public_methods: - raise Exception( - f'Invalid command `{cmd}`. Expected one of {", ".join(map(lambda m: m.upper(), Simulation.public_methods))}.' - ) + if method_name not in Simulation.public_methods: + raise Exception( + f'Invalid command `{cmd}`. Expected one of {", ".join(map(lambda m: m.upper(), Simulation.public_methods))}.' + ) - method = getattr(simulation, method_name) - method_params = list(signature(method).parameters.values()) + method = getattr(simulation, method_name) + method_params = list(signature(method).parameters.values()) - # Allow different number of arguments only for variable argument number (*) - if len(method_params) != len(args) and (len(method_params) < 1 or str(method_params[0])[0] != '*'): - raise Exception( - f'Invalid number of arguments for command `{cmd}`. Expected {len(method_params)}, got {len(args)}.' - ) + # Allow different number of arguments only for variable argument number (*) + if len(method_params) != len(args) and (len(method_params) < 1 or str(method_params[0])[0] != '*'): + raise Exception( + f'Invalid number of arguments for command `{cmd}`. Expected {len(method_params)}, got {len(args)}.' + ) - logger.debug(f'Executing interaction method `{method_name}` with the arguments {args}') + logger.debug(f'Executing interaction method `{method_name}` with the arguments {args}') - try: method(*args) - except: - return False - return True + return True + except SimulationException as e: + # Simulation exception - sane behaviour, but do not continue interpreting + simulation.navigate(f'https://a.test/report/?exception={quote_plus(str(e))}') + return True + except Exception as e: + # Unexpected exception type - not sane, report the exception + simulation.navigate(f'https://a.test/report/?uncaught-exception={quote_plus(type(e).__name__)}&message={quote_plus(str(e))}') + return False \ No newline at end of file diff --git a/bci/browser/interaction/simulation.py b/bci/browser/interaction/simulation.py index d4ba899c..8ca2d407 100644 --- a/bci/browser/interaction/simulation.py +++ b/bci/browser/interaction/simulation.py @@ -6,6 +6,7 @@ from pyvirtualdisplay.display import Display from bci.browser.configuration.browser import Browser as BrowserConfig +from bci.browser.interaction.simulation_exception import SimulationException from bci.evaluations.logic import TestParameters @@ -15,6 +16,7 @@ class Simulation: public_methods: list[str] = [ 'navigate', + 'new_tab', 'click_position', 'click', 'write', @@ -24,6 +26,9 @@ class Simulation: 'hotkey', 'sleep', 'screenshot', + 'report_leak', + 'assert_file_contains', + 'open_file', ] def __init__(self, browser_config: BrowserConfig, params: TestParameters): @@ -50,6 +55,14 @@ def navigate(self, url: str): self.browser_config.open(url) self.sleep(str(self.browser_config.get_navigation_sleep_duration())) + def new_tab(self, url: str): + self.click_position("100", "50%") # focus the browser window + self.hotkey("ctrl", "t") + self.sleep("0.5") + self.write(url) + self.press("enter") + self.sleep(str(self.browser_config.get_navigation_sleep_duration())) + def click_position(self, x: str, y: str): max_x, max_y = gui.size() @@ -83,3 +96,19 @@ def screenshot(self, filename: str): filename = f'{self.params.evaluation_configuration.project}-{self.params.mech_group}-{filename}-{type(self.browser_config).__name__}-{self.browser_config.version}.jpg' filepath = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../../../logs/screenshots', filename) gui.screenshot(filepath) + + def report_leak(self): + self.navigate(f'https://a.test/report/?leak={self.params.mech_group}') + + def assert_file_contains(self, filename: str, content: str): + filepath = os.path.join('/root/Downloads', filename) + + if not os.path.isfile(filepath): + raise SimulationException(f'file-{filename}-does-not-exist') + + with open(filepath, 'r') as f: + if content not in f.read(): + raise SimulationException(f'file-{filename}-does-not-contain-{content}') + + def open_file(self, filename: str): + self.navigate(f'file:///root/Downloads/{filename}') diff --git a/bci/browser/interaction/simulation_exception.py b/bci/browser/interaction/simulation_exception.py new file mode 100644 index 00000000..02f022d0 --- /dev/null +++ b/bci/browser/interaction/simulation_exception.py @@ -0,0 +1,5 @@ +class SimulationException(Exception): + """ + Common class for exceptions thrown upon failed experiment assertions defined by script.cmd. + """ + pass diff --git a/bci/evaluations/custom/default_files/script.cmd b/bci/evaluations/custom/default_files/script.cmd index 749e7a21..692d64bc 100644 --- a/bci/evaluations/custom/default_files/script.cmd +++ b/bci/evaluations/custom/default_files/script.cmd @@ -1,6 +1,8 @@ # TODO - add your interaction script using the commands +### Production commands # NAVIGATE url +# NEW_TAB url # CLICK_POSITION x y where x and y are absolute numbers or screen percentages # CLICK element_id where element_id is one of one, two, three, four, five, six # WRITE text @@ -9,4 +11,10 @@ # RELEASE key # HOTKEY key1 key2 ... # SLEEP seconds where seconds is a float or an int +# REPORT_LEAK +# ASSERT_FILE_CONTAINS file content if the downloaded file exists and contains the given content as a substring, the evaluation continues +# otherwise the evaluation terminates and the exact reason is reported + +### Debugging commands # SCREENSHOT file_name +# OPEN_FILE file \ No newline at end of file diff --git a/bci/web/blueprints/experiments.py b/bci/web/blueprints/experiments.py index a5942403..6f023a69 100644 --- a/bci/web/blueprints/experiments.py +++ b/bci/web/blueprints/experiments.py @@ -140,7 +140,7 @@ def report_leak_if_contains(expected_header_name: str, expected_header_value: st ) -@exp.route("///.py") +@exp.route("///.py", methods=["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"]) def python_evaluation(project: str, experiment: str, file_name: str): """ Evaluates the python script and returns its result. diff --git a/bci/web/vue/src/interaction_script_mode.js b/bci/web/vue/src/interaction_script_mode.js index 4413d521..3530283e 100644 --- a/bci/web/vue/src/interaction_script_mode.js +++ b/bci/web/vue/src/interaction_script_mode.js @@ -1,4 +1,4 @@ -const KEYWORDS = "NAVIGATE|CLICK_POSITION|CLICK|WRITE|PRESS|HOLD|RELEASE|HOTKEY|SLEEP|SCREENSHOT"; +const KEYWORDS = "NAVIGATE|NEW_TAB|CLICK_POSITION|CLICK|WRITE|PRESS|HOLD|RELEASE|HOTKEY|SLEEP|SCREENSHOT|REPORT_LEAK|ASSERT_FILE_CONTAINS|OPEN_FILE"; ace.define("ace/mode/interaction_script_highlight_rules",["require","exports","module","ace/lib/oop","ace/mode/text_highlight_rules"], function(require, exports, module){"use strict"; const oop = require("../lib/oop"); diff --git a/bci/web/vue/tailwind.config.js b/bci/web/vue/tailwind.config.js index a6fc0f41..9ec93a0c 100644 --- a/bci/web/vue/tailwind.config.js +++ b/bci/web/vue/tailwind.config.js @@ -1,5 +1,7 @@ +import flowbite from 'flowbite/plugin'; + /** @type {import('tailwindcss').Config} */ -module.exports = { +export default { mode: "jit", content: [ "./index.html", @@ -8,7 +10,7 @@ module.exports = { ], darkMode: ['selector'], plugins: [ - require('flowbite/plugin'), + flowbite, ], theme: { extend: { diff --git a/docker-compose.yml b/docker-compose.yml index be151e8c..f3457e2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,7 +101,7 @@ services: - ./experiments/pages/:/www/data/pages/:ro - ./experiments/res/:/www/data/res/:ro - ./logs/:/logs/:rw - - ./logs/:/logs/:rw + - ./logs/screenshots/:/www/data/screenshots/:ro ports: - "80:80" - "443:443" diff --git a/experiments/pages/Support/Downloading/a.test/main/headers.json b/experiments/pages/Support/Downloading/a.test/main/headers.json new file mode 100644 index 00000000..81975b3f --- /dev/null +++ b/experiments/pages/Support/Downloading/a.test/main/headers.json @@ -0,0 +1,6 @@ +[ + { + "key": "Header-Name", + "value": "Header-Value" + } +] \ No newline at end of file diff --git a/experiments/pages/Support/Downloading/a.test/main/index.html b/experiments/pages/Support/Downloading/a.test/main/index.html new file mode 100644 index 00000000..d729770e --- /dev/null +++ b/experiments/pages/Support/Downloading/a.test/main/index.html @@ -0,0 +1,9 @@ + + + + Download + + + \ No newline at end of file diff --git a/experiments/pages/Support/Downloading/script.cmd b/experiments/pages/Support/Downloading/script.cmd new file mode 100644 index 00000000..9641a42f --- /dev/null +++ b/experiments/pages/Support/Downloading/script.cmd @@ -0,0 +1,13 @@ +# Download file short-text.txt with content 123456789 +NAVIGATE https://a.test/Support/Downloading/main + +SLEEP 1 + +# These commands would stop the evaluation and not report a leak +# ASSERT_FILE_CONTAINS i-dont-exist.txt 234 +# ASSERT_FILE_CONTAINS short-text.txt i-am-not-in-the-file + +# This command will continue the evaluation and report a leak +ASSERT_FILE_CONTAINS short-text.txt 234 + +REPORT_LEAK \ No newline at end of file diff --git a/experiments/res/big-gradient.jpg b/experiments/res/big-gradient.jpg new file mode 100644 index 00000000..3deb1f8b Binary files /dev/null and b/experiments/res/big-gradient.jpg differ diff --git a/experiments/res/short-text.txt b/experiments/res/short-text.txt new file mode 100644 index 00000000..e2e107ac --- /dev/null +++ b/experiments/res/short-text.txt @@ -0,0 +1 @@ +123456789 \ No newline at end of file diff --git a/nginx/config/core_dev.conf b/nginx/config/core_dev.conf index be067ad5..5cced6e0 100644 --- a/nginx/config/core_dev.conf +++ b/nginx/config/core_dev.conf @@ -25,3 +25,8 @@ location /api/ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + +location /screenshots/ { + autoindex on; + alias /www/data/screenshots/; +} diff --git a/nginx/config/core_prod.conf b/nginx/config/core_prod.conf index 43ce77b9..81a14e5b 100644 --- a/nginx/config/core_prod.conf +++ b/nginx/config/core_prod.conf @@ -16,3 +16,8 @@ location /api/ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + +location /screenshots/ { + autoindex on; + alias /www/data/screenshots/; +}