Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
1da8973
WiP - interaction script
szymsza Oct 30, 2024
5a516ba
Connect browser interaction to terminal automation
szymsza Oct 30, 2024
6cef759
Install pip requirements in dev container
szymsza Oct 30, 2024
688948f
Simple interaction browser identification
szymsza Oct 30, 2024
b2f1f82
Fix browser log reading
szymsza Oct 30, 2024
e8f56a2
Browser interaction commands interpreter
szymsza Oct 30, 2024
c0e4790
Basic BiDi implementation
szymsza Oct 30, 2024
796bf93
Remove print
szymsza Oct 30, 2024
c2c5580
Simple AutoGUI test experiment
szymsza Oct 31, 2024
b8c02dd
Install AutoGUI
szymsza Oct 31, 2024
1444ff2
Simple AutoGUI usage
szymsza Oct 31, 2024
0acc9c2
Remove debugging ports
szymsza Oct 31, 2024
840190a
Fix opening Firefox
szymsza Oct 31, 2024
c85fa2a
Fix opening Firefox
szymsza Oct 31, 2024
44e5dac
Editing config files through the web UI
szymsza Oct 31, 2024
025c8ec
Interaction script - support blank lines and comments
szymsza Oct 31, 2024
32a6149
Interaction script editor code highlighting
szymsza Oct 31, 2024
b56b17c
Add new Sleep command
szymsza Oct 31, 2024
4775796
Clicking on element IDs
szymsza Oct 31, 2024
641f122
Implement clicking by screen percentage
szymsza Oct 31, 2024
3ede69e
Implement clicking by screen percentage
szymsza Oct 31, 2024
8e1043a
Save screenshots to filesystem
szymsza Oct 31, 2024
63ab107
Fix collector notifications status code
szymsza Nov 1, 2024
dcbc707
Remove redundant styles
szymsza Nov 1, 2024
2fc829b
Add default experiment file values
szymsza Nov 1, 2024
5fbb38b
Refactor experiment
szymsza Nov 1, 2024
6e366bd
Better selectors styling
szymsza Nov 1, 2024
45710c2
Adding config files through the frontend - Vue part
szymsza Nov 1, 2024
acdec3c
Adding config files through the frontend - Python part
szymsza Nov 1, 2024
3ee4d20
More interaction commands
szymsza Nov 1, 2024
52211d0
Styling fix
szymsza Nov 1, 2024
639441c
Final interaction improvements
szymsza Nov 1, 2024
8e4af95
Remove old experiment
szymsza Nov 1, 2024
e815589
Merge branch 'dev' into dev-auto-gui
GJFR Nov 12, 2024
a2e61c2
Fix button to add script
GJFR Nov 13, 2024
792447e
Rename interaction_script.cmd to script.md and small button fix
GJFR Nov 13, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
"Vue.volar"
]
}
}
},

// Install pip requirements
"postCreateCommand": "pip install -r requirements.txt"

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
Expand All @@ -35,9 +38,6 @@
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
// "shutdownAction": "none",

// Uncomment the next line to run commands after the container is created.
// "postCreateCommand": "cat /etc/os-release",

// Configure tool-specific properties.
// "customizations": {},
}
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ nginx/ssl/keys/*
!**/.gitkeep
**/node_modules
**/junit.xml

# Screenshots
logs/screenshots/*
!logs/screenshots/.gitkeep

# Fish shell
$HOME

# JetBrains IDEs
.idea

# Created by https://www.toptal.com/developers/gitignore/api/intellij,python,flask,macos
# Edit at https://www.toptal.com/developers/gitignore?templates=intellij,python,flask,macos

Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ RUN cp /app/scripts/daemon/xvfb /etc/init.d/xvfb
# Install python packages
COPY requirements.txt /app/requirements.txt
RUN pip install --user -r /app/requirements.txt
RUN apt-get install python3-tk python3-xlib gnome-screenshot -y

# Initiate PyAutoGUI
RUN touch /root/.Xauthority && \
xauth add ${HOST}:0 . $(xxd -l 16 -p /dev/urandom)

FROM base AS core
# Copy rest of source code
Expand Down
30 changes: 17 additions & 13 deletions bci/browser/automation/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,35 @@


class TerminalAutomation:

@staticmethod
def run(url: str, args: list[str], seconds_per_visit: int):
logger.debug("Starting browser process...")
def visit_url(url: str, args: list[str], seconds_per_visit: int):
args.append(url)
proc = TerminalAutomation.open_browser(args)
logger.debug(f'Visiting the page for {seconds_per_visit}s')
time.sleep(seconds_per_visit)
TerminalAutomation.terminate_browser(proc, args)

@staticmethod
def open_browser(args: list[str]) -> subprocess.Popen:
logger.debug('Starting browser process...')
logger.debug(f'Command string: \'{" ".join(args)}\'')
with open('/tmp/browser.log', 'a') as file:
proc = subprocess.Popen(
args,
stdout=file,
stderr=file
)
with open('/tmp/browser.log', 'a+') as file:
proc = subprocess.Popen(args, stdout=file, stderr=file)
return proc

time.sleep(seconds_per_visit)
@staticmethod
def terminate_browser(proc: subprocess.Popen, args: list[str]) -> None:
logger.debug('Terminating browser process using SIGINT...')

logger.debug(f'Terminating browser process after {seconds_per_visit}s using SIGINT...')
# Use SIGINT and SIGTERM to end process such that cookies remain saved.
proc.send_signal(signal.SIGINT)
proc.send_signal(signal.SIGTERM)

try:
stdout, stderr = proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
logger.info("Browser process did not terminate after 5s. Killing process through pkill...")
logger.info('Browser process did not terminate after 5s. Killing process through pkill...')
subprocess.run(['pkill', '-2', args[0].split('/')[-1]])

proc.wait()
logger.debug("Browser process terminated.")
logger.debug('Browser process terminated.')
31 changes: 27 additions & 4 deletions bci/browser/configuration/browser.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
import subprocess
from abc import abstractmethod

import bci.browser.binary.factory as binary_factory
Expand All @@ -15,9 +16,13 @@


class Browser:
process: subprocess.Popen | None

def __init__(self, browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, binary: Binary) -> None:
def __init__(
self, browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, binary: Binary
) -> None:
self.browser_config = browser_config
self.process = None
self.eval_config = eval_config
self.binary = binary
self.state = binary.state
Expand All @@ -34,10 +39,22 @@ def visit(self, url: str):
match self.eval_config.automation:
case 'terminal':
args = self._get_terminal_args()
TerminalAutomation.run(url, args, self.eval_config.seconds_per_visit)
TerminalAutomation.visit_url(url, args, self.eval_config.seconds_per_visit)
case _:
raise AttributeError('Not implemented')

def open(self, url: str) -> None:
args = self._get_terminal_args()
args.append(url)
self.process = TerminalAutomation.open_browser(args)

def terminate(self):
if self.process is None:
return

TerminalAutomation.terminate_browser(self.process, self._get_terminal_args())
self.process = None

def pre_evaluation_setup(self):
self.__fetch_binary()

Expand Down Expand Up @@ -80,11 +97,17 @@ def _get_executable_file_path(self) -> str:
return os.path.join(self.__get_execution_folder_path(), self.binary.executable_name)

@abstractmethod
def _get_terminal_args(self):
def _get_terminal_args(self) -> list[str]:
pass

@abstractmethod
def get_navigation_sleep_duration(self) -> int:
pass

@staticmethod
def get_browser(browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, state: State) -> Browser:
def get_browser(
browser_config: BrowserConfiguration, eval_config: EvaluationConfiguration, state: State
) -> Browser:
from bci.browser.configuration.chromium import Chromium
from bci.browser.configuration.firefox import Firefox

Expand Down
3 changes: 3 additions & 0 deletions bci/browser/configuration/chromium.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@

class Chromium(Browser):

def get_navigation_sleep_duration(self) -> int:
return 1

def _get_terminal_args(self) -> list[str]:
assert self._profile_path is not None

Expand Down
8 changes: 6 additions & 2 deletions bci/browser/configuration/firefox.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

from bci import cli
from bci.browser.configuration.browser import Browser
from bci.browser.configuration.options import Default, BlockThirdPartyCookies, PrivateBrowsing, TrackingProtection
from bci.browser.configuration.options import BlockThirdPartyCookies, Default, PrivateBrowsing, TrackingProtection
from bci.browser.configuration.profile import prepare_firefox_profile


SUPPORTED_OPTIONS = [
Default(),
BlockThirdPartyCookies(),
Expand All @@ -21,11 +20,15 @@

class Firefox(Browser):

def get_navigation_sleep_duration(self) -> int:
return 2

def _get_terminal_args(self) -> list[str]:
assert self._profile_path is not None

args = [self._get_executable_file_path()]
args.extend(['-profile', self._profile_path])
args.append('-setDefaultBrowser')
user_prefs = []

def add_user_pref(key: str, value: str | int | bool):
Expand All @@ -45,6 +48,7 @@ def add_user_pref(key: str, value: str | int | bool):
# add_user_pref('network.proxy.type', 1)

add_user_pref('app.update.enabled', False)
add_user_pref('browser.shell.checkDefaultBrowser', False)
if 'default' in self.browser_config.browser_setting:
pass
elif 'btpc' in self.browser_config.browser_setting:
Expand Down
Empty file.
Binary file added bci/browser/interaction/elements/five.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added bci/browser/interaction/elements/four.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added bci/browser/interaction/elements/one.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added bci/browser/interaction/elements/six.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added bci/browser/interaction/elements/three.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added bci/browser/interaction/elements/two.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 57 additions & 0 deletions bci/browser/interaction/interaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import logging
from inspect import signature

from bci.browser.configuration.browser import Browser as BrowserConfig
from bci.browser.interaction.simulation import Simulation
from bci.evaluations.logic import TestParameters

logger = logging.getLogger(__name__)


class Interaction:
browser: BrowserConfig
script: list[str]
params: TestParameters

def __init__(self, browser: BrowserConfig, script: list[str], params: TestParameters) -> None:
self.browser = browser
self.script = script
self.params = params

def execute(self) -> None:
simulation = Simulation(self.browser, self.params)

if self._interpret(simulation):
simulation.sleep(str(self.browser.get_navigation_sleep_duration()))
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

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))}.'
)

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)}.'
)

logger.debug(f'Executing interaction method `{method_name}` with the arguments {args}')

try:
method(*args)
except:
return False

return True
85 changes: 85 additions & 0 deletions bci/browser/interaction/simulation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import os
from time import sleep

import pyautogui as gui
import Xlib.display
from pyvirtualdisplay.display import Display

from bci.browser.configuration.browser import Browser as BrowserConfig
from bci.evaluations.logic import TestParameters


class Simulation:
browser_config: BrowserConfig
params: TestParameters

public_methods: list[str] = [
'navigate',
'click_position',
'click',
'write',
'press',
'hold',
'release',
'hotkey',
'sleep',
'screenshot',
]

def __init__(self, browser_config: BrowserConfig, params: TestParameters):
self.browser_config = browser_config
self.params = params
disp = Display(visible=True, size=(1920, 1080), backend='xvfb', use_xauth=True)
disp.start()
gui._pyautogui_x11._display = Xlib.display.Display(os.environ['DISPLAY'])

def __del__(self):
self.browser_config.terminate()

def parse_position(self, position: str, max_value: int) -> int:
# Screen percentage
if position[-1] == '%':
return round(max_value * (int(position[:-1]) / 100))

# Absolute value in pixels
return int(position)

# --- PUBLIC METHODS ---
def navigate(self, url: str):
self.browser_config.terminate()
self.browser_config.open(url)
self.sleep(str(self.browser_config.get_navigation_sleep_duration()))

def click_position(self, x: str, y: str):
max_x, max_y = gui.size()

gui.moveTo(self.parse_position(x, max_x), self.parse_position(y, max_y))
gui.click()

def click(self, el_id: str):
el_image_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), f'elements/{el_id}.png')
x, y = gui.locateCenterOnScreen(el_image_path)
self.click_position(str(x), str(y))

def write(self, text: str):
gui.write(text, interval=0.1)

def press(self, key: str):
gui.press(key)

def hold(self, key: str):
gui.keyDown(key)

def release(self, key: str):
gui.keyUp(key)

def hotkey(self, *keys: str):
gui.hotkey(*keys)

def sleep(self, duration: str):
sleep(float(duration))

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)
Loading
Loading