-
Notifications
You must be signed in to change notification settings - Fork 76
/
Copy pathaddon.py
364 lines (288 loc) · 13.1 KB
/
addon.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
"""
Promnesia-specific addon wrappers
"""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
import json
from pathlib import Path
from time import sleep
from typing import Optional, Iterator, Sequence
import pytest
from selenium.common.exceptions import TimeoutException
from selenium.webdriver import Remote as Driver
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait as Wait
from promnesia.logging import LazyLogger
from addon_helper import AddonHelper
from webdriver_utils import frame_context, is_visible, wait_for_alert
logger = LazyLogger('promnesia-tests', level='debug')
from promnesia.common import measure as measure_orig
@contextmanager
def measure(*args, **kwargs):
kwargs['logger'] = logger
with measure_orig(*args, **kwargs) as m:
yield m
def get_addon_source(kind: str) -> Path:
# TODO compile first?
addon_path = (Path(__file__).parent.parent / 'extension' / 'dist' / kind).absolute()
assert addon_path.exists()
assert (addon_path / 'manifest.json').exists()
return addon_path
LOCALHOST = 'http://localhost'
class Command:
# TODO assert these against manifest?
ACTIVATE = '_execute_browser_action'
ACTIVATE_V3 = '_execute_action'
MARK_VISITED = 'mark_visited'
SEARCH = 'search'
PROMNESIA_SIDEBAR_ID = 'promnesia-sidebar'
@dataclass
class Sidebar:
addon: 'Addon'
@property
def driver(self) -> Driver:
return self.addon.helper.driver
@contextmanager
def ctx(self) -> Iterator[WebElement]:
selector = (By.XPATH, '//iframe[contains(@id, "promnesia-frame")]')
wait = 5 # if you want to decrease this, make sure test_sidebar_navigation isn't failing
frame_element = Wait(self.driver, timeout=wait).until(
EC.presence_of_element_located(selector),
)
frames = self.driver.find_elements(*selector)
# TODO uncomment it later when sidebar is injected gracefully...
# assert len(frames) == 1, frames # just in case
frame_id = frame_element.get_attribute('id')
with frame_context(self.driver, frame_id) as frame:
assert frame is not None
yield frame
@property
def available(self) -> bool:
try:
with self.ctx():
return True
except TimeoutException:
return False
@property
def visible(self) -> bool:
loc = (By.ID, PROMNESIA_SIDEBAR_ID)
with self.ctx():
Wait(self.driver, timeout=5).until(EC.presence_of_element_located(loc))
# NOTE: document in JS here is in the context of iframe
return is_visible(self.driver, self.driver.find_element(*loc))
def open(self) -> None:
assert not self.visible
self.addon.activate()
with measure('Sidebar.open') as m:
while not self.visible:
assert m() <= 10, 'timeout'
sleep(0.001)
def close(self) -> None:
assert self.visible
self.addon.activate()
with measure('Sidebar.close') as m:
while self.visible:
assert m() <= 10, 'timeout'
sleep(0.001)
@property
def filters(self) -> list[WebElement]:
# TODO hmm this only works within sidebar frame context
# but if we add with self.ctx() here, it seems unhappy with enterinng the context twice
# do something about it later..
outer = self.driver.find_element(By.ID, 'promnesia-sidebar-filters')
return outer.find_elements(By.CLASS_NAME, 'src')
@property
def visits(self) -> list[WebElement]:
return self.driver.find_elements(By.XPATH, '//*[@id="visits"]/li')
def trigger_search(self) -> None:
# this should be the window with extension
cur_window_handles = self.driver.window_handles
with self.ctx():
self.driver.find_element(By.ID, 'button-search').click()
self.addon.wait_for_search_tab(cur_window_handles)
def trigger_mark_visited(self) -> None:
with self.ctx():
self.driver.find_element(By.ID, 'button-mark').click()
def trigger_close(self) -> None:
with self.ctx():
self.driver.find_element(By.ID, 'button-close').click()
@dataclass
class OptionsPage:
helper: AddonHelper
def open(self) -> None:
self.helper.open_page(self.helper.options_page_name)
# make sure settings are loaded first -- otherwise we might get race conditions when we try to set them in tests
Wait(self.helper.driver, timeout=5).until(EC.presence_of_element_located((By.ID, 'promnesia-settings-loaded')))
def configure_extension(
self,
*,
host: Optional[str] = None,
port: Optional[str] = None,
show_dots: bool = True,
highlights: Optional[bool] = None,
blacklist: Optional[Sequence[str]] = None,
excludelists: Optional[Sequence[str]] = None,
notify_contexts: Optional[bool] = None,
position: Optional[str] = None,
verbose_errors: bool = True,
) -> None:
driver = self.helper.driver
def set_checkbox(cid: str, value: bool) -> None:
cb = driver.find_element(By.ID, cid)
selected = cb.is_selected()
if selected != value:
cb.click()
# TODO log properly
print(f"Setting: port {port}, show_dots {show_dots}")
self.open()
if host is not None or port is not None:
self._set_endpoint(host=host, port=port)
if show_dots is not None:
set_checkbox('mark_visited_always_id', show_dots)
# TODO not sure, should be False for demos?
set_checkbox('verbose_errors_id', verbose_errors)
if highlights is not None:
set_checkbox('highlight_id', highlights)
if notify_contexts is not None:
set_checkbox('contexts_popup_id', notify_contexts)
if position is not None:
self._set_position(position)
if blacklist is not None:
bl = driver.find_element(By.ID, 'global_excludelist_id') # .find_element_by_tag_name('textarea')
bl.click()
# ugh, that's hacky. presumably due to using Codemirror?
bla = driver.switch_to.active_element
# ugh. doesn't work, .text always returns 0...
# if len(bla.text) > 0:
# for some reason bla.clear() isn't working...
# results in ElementNotInteractableException: Element <textarea> could not be scrolled into view
bla.send_keys(Keys.CONTROL + 'a')
bla.send_keys(Keys.BACKSPACE)
bla.send_keys('\n'.join(blacklist))
if excludelists is not None:
excludelists_json = json.dumps(excludelists)
bl = driver.find_element(By.ID, 'global_excludelists_ext_id') # .find_element_by_tag_name('textarea')
bl.click()
# ugh, that's hacky. presumably due to using Codemirror?
bla = driver.switch_to.active_element
# ugh. doesn't work, .text always returns 0...
# if len(bla.text) > 0:
# for some reason bla.clear() isn't working...
# results in ElementNotInteractableException: Element <textarea> could not be scrolled into view
bla.send_keys(Keys.CONTROL + 'a')
bla.send_keys(Keys.BACKSPACE)
bla.send_keys(excludelists_json)
self._save()
def _save(self) -> None:
se = self.helper.driver.find_element(By.ID, 'save_id')
se.click()
wait_for_alert(self.helper.driver).accept()
def _set_position(self, settings: str) -> None:
field = self.helper.driver.find_element(By.XPATH, '//*[@id="position_css_id"]')
area = field.find_element(By.CLASS_NAME, 'cm-content')
area.click()
# for some reason area.clear() caused
# selenium.common.exceptions.ElementNotInteractableException: Message: Element <textarea> could not be scrolled into view
def contents() -> str:
return self.helper.driver.execute_script('return arguments[0].cmView.view.state.doc.toString()', area)
# TODO FFS. these don't seem to work??
# count = len(area.get_attribute('value'))
# and this only returns visible porition of the text??? so only 700 characters or something
# count = len(field.text)
# count += 100 # just in case
count = 3000 # meh
# focus ends up at some random position, so need both backspace and delete
area.send_keys(*([Keys.BACKSPACE] * count + [Keys.DELETE] * count))
assert contents() == ''
area.send_keys(settings)
# just in case, also need to remove spaces to workaround indentation
assert [l.strip() for l in contents().splitlines()] == [l.strip() for l in settings.splitlines()]
def _set_endpoint(self, *, host: Optional[str], port: Optional[str]) -> None:
# todo rename to 'backend_id'?
ep = self.helper.driver.find_element(By.ID, 'host_id')
ep.clear()
# sanity check -- make sure there are no race conditions with async operations
assert ep.get_attribute('value') == ''
if host is None:
return
assert port is not None
ep.send_keys(f'{host}:{port}')
assert ep.get_attribute('value') == f'{host}:{port}'
# TODO gradually replace TestHelper and other older stuff
@dataclass
class Addon:
helper: AddonHelper
@property
def options_page(self) -> OptionsPage:
return OptionsPage(helper=self.helper)
def open_search_page(self, query: str = "") -> None:
self.helper.open_page('search.html' + query)
Wait(self.helper.driver, timeout=10).until(
EC.presence_of_element_located((By.ID, 'visits')),
)
@property
def sidebar(self) -> Sidebar:
driver = self.helper.driver
if driver.name == 'chrome':
browser_version = tuple(map(int, driver.capabilities['browserVersion'].split('.')))
driver_version = tuple(map(int, driver.capabilities['chrome']['chromedriverVersion'].split(' ')[0].split('.')))
last_working = (113, 0, 5623, 0)
if browser_version > last_working or driver_version > last_working:
# NOTE: feel free to comment this out if necessary, it's just to avoid hours of debugging
raise RuntimeError(
f"""
NOTE: you're using chrome {browser_version} with chromedriver {driver_version}.
Some tests aren't working with recent Chrome versions (later than {last_working}) due to regressions in chromedriver.
See https://bugs.chromium.org/p/chromedriver/issues/detail?id=4440
"""
)
return Sidebar(addon=self)
def activate(self) -> None:
# TODO the activate command could be extracted from manifest too?
cmd = {
2: Command.ACTIVATE,
3: Command.ACTIVATE_V3, # meh
}[self.helper.manifest_version]
self.helper.trigger_command(cmd)
def mark_visited(self) -> None:
self.helper.trigger_command(Command.MARK_VISITED)
def search(self) -> None:
# cur_window_handles = self.driver.window_handles
self.helper.trigger_command(Command.SEARCH)
# self.wait_for_search_tab(cur_window_handles)
def configure(self, **kwargs) -> None:
self.options_page.configure_extension(**kwargs)
def open_context_menu(self) -> None:
# looks like selenium can't interact with browser context menu...
assert not self.helper.headless
driver = self.helper.driver
chain = ActionChains(driver)
chain.move_to_element(driver.find_element(By.TAG_NAME, 'h1')).context_click().perform()
if driver.name == 'chrome':
offset = 2 # Inspect, View page source
else:
offset = 0
self.helper.gui_write(['up'] + ['up'] * offset + ['enter'], interval=0.5)
# TODO this doesn't belong to this class really, think about it
def move_to(self, element) -> None:
ActionChains(self.helper.driver).move_to_element(element).perform()
def wait_for_search_tab(self, cur_window_handles) -> None:
driver = self.helper.driver
# for some reason the webdriver's context stays the same even when new tab is opened
# ugh. not sure why it's so elaborate, but that's what stackoverflow suggested
Wait(driver, timeout=5).until(EC.number_of_windows_to_be(len(cur_window_handles) + 1))
new_windows = set(driver.window_handles) - set(cur_window_handles)
assert len(new_windows) == 1, new_windows
[new_window] = new_windows
driver.switch_to.window(new_window)
Wait(driver, timeout=5).until(EC.presence_of_element_located((By.ID, 'promnesia-search')))
@pytest.fixture
def addon(driver: Driver) -> Iterator[Addon]:
addon_source = get_addon_source(kind=driver.name)
helper = AddonHelper(driver=driver, addon_source=addon_source)
yield Addon(helper=helper)