pmt-tools 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.3
2
+ Name: pmt-tools
3
+ Version: 0.1.0
4
+ Summary: Helpers for Pyodide-MkDocs-Theme selenium testing
5
+ Author: Frédéric Zinelli
6
+ Author-email: frederic.zinelli@gmail.com
7
+ Requires-Python: >=3.9, <4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: selenium (>=4.36)
15
+ Requires-Dist: selenium-query
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Pmt Tools
19
+
20
+
21
+
22
+ ## Getting started
23
+
24
+ To make it easy for you to get started with GitLab, here's a list of recommended next steps.
25
+
26
+ Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
27
+
28
+ ## Add your files
29
+
30
+ * [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
31
+ * [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
32
+
33
+ ```
34
+ cd existing_repo
35
+ git remote add origin https://gitlab.com/frederic-zinelli/pmt_tools.git
36
+ git branch -M main
37
+ git push -uf origin main
38
+ ```
39
+
40
+ ## Integrate with your tools
41
+
42
+ * [Set up project integrations](https://gitlab.com/frederic-zinelli/pmt_tools/-/settings/integrations)
43
+
44
+ ## Collaborate with your team
45
+
46
+ * [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
47
+ * [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
48
+ * [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
49
+ * [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
50
+ * [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
51
+
52
+ ## Test and Deploy
53
+
54
+ Use the built-in continuous integration in GitLab.
55
+
56
+ * [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
57
+ * [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
58
+ * [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
59
+ * [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
60
+ * [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
61
+
62
+ ***
63
+
64
+ # Editing this README
65
+
66
+ When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
67
+
68
+ ## Suggestions for a good README
69
+
70
+ Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
71
+
72
+ ## Name
73
+ Choose a self-explaining name for your project.
74
+
75
+ ## Description
76
+ Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
77
+
78
+ ## Badges
79
+ On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
80
+
81
+ ## Visuals
82
+ Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
83
+
84
+ ## Installation
85
+ Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
86
+
87
+ ## Usage
88
+ Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
89
+
90
+ ## Support
91
+ Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
92
+
93
+ ## Roadmap
94
+ If you have ideas for releases in the future, it is a good idea to list them in the README.
95
+
96
+ ## Contributing
97
+ State if you are open to contributions and what your requirements are for accepting them.
98
+
99
+ For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
100
+
101
+ You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
102
+
103
+ ## Authors and acknowledgment
104
+ Show your appreciation to those who have contributed to the project.
105
+
106
+ ## License
107
+ For open source projects, say how it is licensed.
108
+
109
+ ## Project status
110
+ If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
111
+
@@ -0,0 +1,93 @@
1
+ # Pmt Tools
2
+
3
+
4
+
5
+ ## Getting started
6
+
7
+ To make it easy for you to get started with GitLab, here's a list of recommended next steps.
8
+
9
+ Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
10
+
11
+ ## Add your files
12
+
13
+ * [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
14
+ * [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
15
+
16
+ ```
17
+ cd existing_repo
18
+ git remote add origin https://gitlab.com/frederic-zinelli/pmt_tools.git
19
+ git branch -M main
20
+ git push -uf origin main
21
+ ```
22
+
23
+ ## Integrate with your tools
24
+
25
+ * [Set up project integrations](https://gitlab.com/frederic-zinelli/pmt_tools/-/settings/integrations)
26
+
27
+ ## Collaborate with your team
28
+
29
+ * [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
30
+ * [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
31
+ * [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
32
+ * [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
33
+ * [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
34
+
35
+ ## Test and Deploy
36
+
37
+ Use the built-in continuous integration in GitLab.
38
+
39
+ * [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
40
+ * [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
41
+ * [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
42
+ * [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
43
+ * [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
44
+
45
+ ***
46
+
47
+ # Editing this README
48
+
49
+ When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
50
+
51
+ ## Suggestions for a good README
52
+
53
+ Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
54
+
55
+ ## Name
56
+ Choose a self-explaining name for your project.
57
+
58
+ ## Description
59
+ Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
60
+
61
+ ## Badges
62
+ On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
63
+
64
+ ## Visuals
65
+ Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
66
+
67
+ ## Installation
68
+ Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
69
+
70
+ ## Usage
71
+ Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
72
+
73
+ ## Support
74
+ Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
75
+
76
+ ## Roadmap
77
+ If you have ideas for releases in the future, it is a good idea to list them in the README.
78
+
79
+ ## Contributing
80
+ State if you are open to contributions and what your requirements are for accepting them.
81
+
82
+ For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
83
+
84
+ You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
85
+
86
+ ## Authors and acknowledgment
87
+ Show your appreciation to those who have contributed to the project.
88
+
89
+ ## License
90
+ For open source projects, say how it is licensed.
91
+
92
+ ## Project status
93
+ If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
@@ -0,0 +1,14 @@
1
+
2
+ from .runners import Runner, Button, ButtonHolder, PyBtn, AutoRun
3
+ from .terminal import Terminal
4
+ from .ide import Ide, IdeConfig, CheckBtnColor , CorrRemProfile
5
+ from .qcm import Qcm, QcmStructure
6
+ from .tabbed import TabbedContent
7
+
8
+
9
+
10
+ def check(msg, actual, exp=None):
11
+ if exp is None:
12
+ exp = True
13
+ msg += f":\n{actual!r} should be {exp!r}"
14
+ assert actual == exp, msg
@@ -0,0 +1,330 @@
1
+ import json
2
+ from math import inf
3
+ from dataclasses import dataclass
4
+ from typing import Literal, Union
5
+ from contextlib import nullcontext
6
+
7
+ from selenium.webdriver import Keys
8
+ from selenium.webdriver.common.alert import Alert
9
+
10
+ from .runners import ButtonHolder
11
+ from .terminal import Terminal
12
+
13
+
14
+
15
+
16
+
17
+ @dataclass
18
+ class IdeConfig:
19
+ count: Union[float,int,None]
20
+ has_check_btn: bool
21
+ has_corr_and_reveal_btns: bool
22
+ revealed: bool = False
23
+
24
+ @property
25
+ def has_counter(self):
26
+ return self.count is not None
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+ class CorrRemProfile:
35
+ hidden: 'CorrRemProfile' = "hidden..."
36
+ none: 'CorrRemProfile' = "none..."
37
+ corr: 'CorrRemProfile' = "Solution"
38
+ rem: 'CorrRemProfile' = "Remarques"
39
+ corr_rem: 'CorrRemProfile' = "Solution & Remarques"
40
+
41
+
42
+ class CheckBtnColor:
43
+ success: 'CheckBtnColor' = "green"
44
+ failure: 'CheckBtnColor' = "red"
45
+ default: 'CheckBtnColor' = ""
46
+
47
+
48
+
49
+
50
+
51
+
52
+ @dataclass(repr=False)
53
+ class BaseIde(Terminal):
54
+ """
55
+ WARNING : `.get.text` extracted from self.editor and self.terminal is "bare text", meaning
56
+ empty lines are actually ignored => DO NOT USE THIS.
57
+ """
58
+
59
+ def __post_init__(self):
60
+ super().__post_init__()
61
+ sol_div = "#" + self.elt.get.id.replace('global', 'solution')
62
+ self.solution = self.elt.find(sol_div, in_page=True)
63
+ self.editor = self.elt.find('.ace_editor textarea')
64
+ self.terminal = self.elt.find('.terminal-scroller')
65
+ self.counter = self.elt.find('.compteur')
66
+ self.button = ButtonHolder.build_buttons(
67
+ self,
68
+ '.stdout-ctrl',
69
+ '.stdout-wraps-btn',
70
+ 'span.comment',
71
+ '.ide-split-screen',
72
+ '.ide-full-screen',
73
+ *(
74
+ f"button[btn_kind={ kind }]"
75
+ for kind in "play check download upload restart save zip corr_btn show".split()
76
+ )
77
+ )
78
+
79
+
80
+ def get_storage(self) -> dict:
81
+ storage = self.run_js(f"return localStorage.getItem('{ self._runner_id }')")
82
+ return storage and json.loads(storage) or {}
83
+
84
+
85
+ def reset(self):
86
+ """ Restart the IDE, NOT using the button, because it's a mess with the popup... """
87
+ self.run_js('{js_runner}.resetElement()')
88
+
89
+
90
+ def validate_and_check_btn_becomes(self, css_value:CheckBtnColor, validate=True):
91
+ if validate:
92
+ self.button.check.click()
93
+ self.check_validation_btn_color(css_value)
94
+
95
+
96
+ def check_validation_btn_color(self, css_value:CheckBtnColor):
97
+ self.button.check.elt.check.css({'--ide-btn-color': css_value})
98
+
99
+
100
+ def has_orange_box(self, expected=True, *, msg=""):
101
+ checker = self.button.check.elt.check(
102
+ f"The validation button should{ ' not'*(not expected) } have had the orange box."
103
+ f"\n\nOn: {msg or self.elt}"
104
+ )
105
+ if expected:
106
+ checker.has_class('dirty-validation')
107
+ else:
108
+ checker.not_.has_class('dirty-validation')
109
+
110
+
111
+ # def reset_global_N(self, n=0):
112
+ # """
113
+ # Set the global N value that is controlling the outcomes of some IDE (see py_libs) to
114
+ # the given value (0 by default). This is done be going through a terminal command.
115
+ # """
116
+ # self.run_term_cmd(f"N={n}")
117
+
118
+ def check_has_src_hash_msg(self, has_it=True):
119
+ exp = """\
120
+ Une version plus récente du code existe.
121
+ Veuillez copier vos éventuelles modifications puis réinitialiser l'IDE.
122
+ >>>
123
+ """ if has_it else ">>> \n"
124
+ self.check_term_content(exp)
125
+
126
+
127
+ def zip_upload(self):
128
+ """
129
+ Triggers the zip upload logistic on the underlying IDE, bypassing the drag&drop thing.
130
+ The JS File object in window.seleniumUpload must already exist.
131
+ """
132
+ self.run_js("{js_runner}.dropArchiveFactory()()", async_=True)
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+ class IdeCodeEditor(BaseIde):
141
+
142
+ def set_python_global_N(self, n:int=0):
143
+ """ Override the N value for the `auto_N` logistic in PMT tests """
144
+ self.run_js(f"pyodide.runPython('N={n}')")
145
+
146
+ def clear_editor(self):
147
+ self.set_editor_code("")
148
+
149
+
150
+ def set_editor_code(self, code:str, do_save=False):
151
+ """ Replace the editor's content (save in the localeStorage if do_save is True). """
152
+ # code = code.replace('"', '\\"')
153
+ do_save = json.dumps(do_save)
154
+ self.run_js(f"""{{js_runner}}.applyCodeToEditorAndSave(`{ code }`, {do_save})""")
155
+
156
+
157
+ def get_editor_code(self):
158
+ """ Extract the current content of the editor (no squashed empty lines!). """
159
+ return self.run_js("return {js_runner}.getCodeToTest()")
160
+
161
+
162
+ def check_code_editor(self, exp:str, msg:str="", use_repr=True, contains=False):
163
+ actual = self.get_editor_code()
164
+ s_act,s_exp = actual, exp
165
+ if use_repr:
166
+ s_act,s_exp = map(repr, (s_act,s_exp))
167
+
168
+ verb = 'CONTAIN' if contains else 'BE'
169
+ msg = (msg or "Wrong editor content:") + f'\ACTUAL:\n\n{s_act}\n\nSHOULD {verb}:\n\n{s_exp}'
170
+ ok = exp in actual if contains else actual == exp
171
+ assert ok, msg
172
+
173
+
174
+ def clean_then_type_in_editor(self, *content):
175
+ self.set_editor_code('')
176
+ self.send_keys(*content)
177
+
178
+
179
+ def send_keys(self, *content):
180
+ self.editor.scroll_to().send_keys(*content).go
181
+
182
+
183
+ def editor_shortcut(self, keys:str, *, exec=False, pause=None):
184
+ self._send_shortcut(keys, exec=exec, target=self.editor, pause=pause)
185
+
186
+
187
+
188
+
189
+
190
+
191
+ class IdeCorrRemsCounter(BaseIde):
192
+ """
193
+ Regroup the counters + corr and REM logistic.
194
+ """
195
+
196
+
197
+ def check_count(self, exp: Union[int,float,None], msg=""):
198
+ """
199
+ Check that the counter (tries) has the expected value. Use `inf` to check for infinity.
200
+ """
201
+ if exp is None:
202
+ self.counter.check(msg).text("")
203
+ else:
204
+ assert self.counter.get.text, 'The compteur should hold some text'
205
+ tries = self.elt.find('.compteur > *')
206
+ tries.check.exists()
207
+ n_tries = tries[1].get.text
208
+ exp = "∞" if exp==inf else str(exp)
209
+ assert n_tries == exp, f"{ msg or 'Number of attempts left' }: { n_tries } should be { exp }"
210
+
211
+
212
+ def click_corr_rem(self):
213
+ """
214
+ Click on the corr & REM admonition to open/close it, then return the details's Getter.
215
+ """
216
+ details = self.solution.find('details').scroll_to()
217
+ self.solution.check.css(display='block')
218
+ details.click().go
219
+ return details
220
+
221
+
222
+ def check_corr_rem_config(self, profile:CorrRemProfile, finally_close=False):
223
+ """
224
+ Check that the corr/REMs have the desired contents/config:
225
+
226
+ WARNING: for positive profile values, the details element must be already revealed,
227
+ and it will be opened automatically during the test, if it is supposed to be visible.
228
+ If @finally_close is True, close the admonition at the end of the tests.
229
+
230
+ * -1: not revealed yet -> check hidden div
231
+ * 0: no corr or REM
232
+ * 1: corr but no rem -> check title + no fake h3 in the content
233
+ * 2: no corr but REM -> check title + no fake h3 in the content
234
+ * 3: corr + REM -> check title + fake h3 present
235
+ """
236
+ if profile == CorrRemProfile.hidden:
237
+ self.solution.check.has_class('py_mk_hidden')
238
+ self.solution.check.css(display='none')
239
+
240
+ elif profile == CorrRemProfile.none:
241
+ self.solution.check("The div should be hidden...").css(display='block')
242
+ self.solution.check("...because the div should be empty").text('')
243
+
244
+ else:
245
+ self.solution.check('The solution div should be visible').css(display='block')
246
+ details = self.click_corr_rem()
247
+ details.find('summary').check.text(profile)
248
+
249
+ fake_h3 = details.find("span.rem_fake_h3")
250
+ if profile == CorrRemProfile.corr_rem:
251
+ fake_h3.check.exists()
252
+ fake_h3.check.text("Remarques :")
253
+ else:
254
+ fake_h3.check.exists(exp=False)
255
+
256
+ if finally_close:
257
+ details.click().go
258
+
259
+
260
+
261
+
262
+
263
+ class IdeScreenModes(IdeCodeEditor):
264
+ """
265
+ Regroup the logistic for split and full screen modes.
266
+ """
267
+
268
+
269
+ def is_full_screen(self, enter:bool):
270
+ msg = f"{self.elt._full_css} should { ' not'*(not enter) } be in full screen mode."
271
+ assert self.run_js("return {js_runner}.isFullScreen") == enter, msg
272
+
273
+ def check_btn_full_screen(self, *, enter:bool):
274
+ self.button.full_screen.click(extra_delay=0.25)
275
+ self.is_full_screen(enter)
276
+
277
+ def check_shortcut_full_screen(self, *, enter:bool):
278
+ self.send_keys("x", Keys.BACKSPACE ,Keys.ESCAPE)
279
+ self.pause(0.25)
280
+ self.is_full_screen(enter)
281
+
282
+
283
+
284
+ def is_split_screen(self, enter:bool, alone=True):
285
+ msg = f"{self.elt._full_css} should { ' not'*(not enter) } be in two columns mode."
286
+ assert self.run_js("return {js_runner}.isInSplit") == enter, msg
287
+ self.elt.find('#pmt-top-div #'+self._runner_id, in_page=True).check.exists(enter or not alone)
288
+
289
+
290
+ def check_btn_split_screen(self, *, enter:bool):
291
+ self.button.two_cols.click()
292
+ self.is_split_screen(enter)
293
+
294
+ def check_shortcut_split_screen(self, *, enter:bool):
295
+ self.editor_shortcut("Alt+:")
296
+ self.is_split_screen(enter)
297
+
298
+
299
+
300
+ def check_placeholder_is_in_place(self):
301
+ self.elt.find(f'#pmt-ide-placeholder + #solution_{self._runner_id}', in_page=True).check(
302
+ "The placeholder element should be present just before the IDE solution div."
303
+ ).exists()
304
+
305
+ def check_is_back_in_place(self):
306
+ self.elt.find(f'#global_{self._runner_id} + #solution_{self._runner_id}', in_page=True).check(
307
+ "The IDE should be just before its solution div again."
308
+ ).exists()
309
+
310
+ def check_is_split_full_height(self):
311
+ body = self.elt.find('body', in_page=True)
312
+ H = body.get.rect['height']
313
+ header = body.find('header').get.rect['height']
314
+ top_H = body.find('#pmt-top-div').get.rect['height']
315
+ ide_H = self.elt.get.rect['height']
316
+
317
+ assert top_H == H-header, "the #pmt-top-div element should occupy the full viewport (except for the header)"
318
+ assert ide_H == top_H, "The IDE in split mode should have the height of the #pmt-top-div element"
319
+
320
+
321
+
322
+
323
+
324
+
325
+
326
+
327
+
328
+
329
+ class Ide(IdeScreenModes, IdeCodeEditor, IdeCorrRemsCounter):
330
+ """ Global /common IDE class """
@@ -0,0 +1,182 @@
1
+ from dataclasses import dataclass
2
+ from typing import ClassVar, Dict, List, Tuple, Type, TYPE_CHECKING, Union
3
+
4
+ from ..selenium_query import Getter
5
+
6
+ if TYPE_CHECKING:
7
+ from..conftest import BaseSeleniumPage
8
+
9
+
10
+
11
+
12
+
13
+
14
+ @dataclass
15
+ class QcmStructure:
16
+
17
+ items_per_question: List[int]
18
+ """ Number of choices for each question. """
19
+
20
+ are_squares: List[bool]
21
+ """ Are the questions multi (square->True) or single choice (single->False)? """
22
+
23
+ correct: List[ Union[int, Tuple[int]] ]
24
+ """
25
+ Give the NUMBER (not the indices!) of the correct items for each question.
26
+ Use a tuple for "multi" questions.
27
+ """
28
+
29
+ shuffle_questions: bool
30
+ """ Are the questions shuffled? """
31
+
32
+ shuffled_items: List[bool]
33
+ """ Are the items of each question shuffled? """
34
+
35
+ mask: bool = False
36
+
37
+
38
+ def __post_init__(self):
39
+ assert len(self.shuffled_items) == len(self.are_squares) == len(self.items_per_question), (
40
+ "Wrong QcmStructure data: all lists should have the same length"
41
+ )
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+ @dataclass
56
+ class Qcm:
57
+ """
58
+ Represent a PMT Qcm element.
59
+
60
+ WARNING: depending on the QCM having or not an admonition, the top level element may vary:
61
+ - `.qcm_no_admo.inner.py_mk_admonition_qcm` for QCMs WITHOUT admonitions
62
+ - `.admonition.py_mk_admonition_qcm` for QCMs with admonitions. This div will also hold a deeper
63
+ `.inner.py_mk_admonition_qcm` element...
64
+ """
65
+ elt: Getter
66
+
67
+ QUESTION_RULE: ClassVar[str] = "li.py_mk_question_qcm"
68
+ ITEM_RULE: ClassVar[str] = "li.py_mk_item_qcm"
69
+
70
+ def __post_init__(self):
71
+ self.no_admo = self.elt.has_class('qcm_no_admo')
72
+ self.counter = self.elt.find('.qcm-counter')
73
+ self.mask = self.elt.find('.mask-svg')
74
+ self.check_btn = self.elt.find('.check-btn')
75
+ self.reset_btn = self.elt.find('.restart-btn')
76
+
77
+ self.inner_div = self.elt.find('.inner.py_mk_admonition_qcm')
78
+ self.shuffle_questions = self.inner_div.has_class('qcm_shuffle')
79
+
80
+
81
+ @classmethod
82
+ def from_page(cls, page: Type['BaseSeleniumPage'] ):
83
+ rule = ".qcm_no_admo.layout-qcm-wrapper, .admonition.py_mk_admonition_qcm, details.py_mk_admonition_qcm"
84
+ qcms = [ cls(qcm) for qcm in page.find(rule) ]
85
+ return qcms
86
+
87
+ def find(self, *a, **kw):
88
+ return self.elt.find(*a, **kw)
89
+
90
+ def get_questions(self):
91
+ return self.inner_div.find(self.QUESTION_RULE)
92
+
93
+ def get_items(self, question:Getter):
94
+ return question.find(self.ITEM_RULE)
95
+
96
+ def get_q_i_2D_array(self, target:str=""):
97
+ """
98
+ @target: final targeted element/Getter on each item. By default, the elements are `Getter<li>`.
99
+ Built at runtime, so can be used after shuffling.
100
+ """
101
+ out: List[List[Getter]] = []
102
+ for q in self.get_questions():
103
+ items = q.find(f"{ self.ITEM_RULE } { target }".rstrip())
104
+ out.append(items)
105
+ return out
106
+
107
+ def get_rems(self):
108
+ return self.elt.find('details.py_mk_comment_qcm')
109
+
110
+ def check_counter(self, expected:int):
111
+ self.counter.check.has_text(f"{expected}/")
112
+
113
+
114
+ def select(self, q_and_i:str):
115
+ """
116
+ Click on all the given (space separated) "question.items" (in this format `N.N`, as
117
+ NUMBERS, NOT INDICES).
118
+ To make sure the elements are clickable, they are handled in REVERSED order, the page
119
+ being scrolled at each new question to put the current item at the bottom of the page.
120
+ """
121
+ qis = self.get_q_i_2D_array('label')
122
+ last_q = None
123
+ lst = q_and_i.split()
124
+ for s in lst:
125
+ q,i = map(int, s.split('.'))
126
+ q-=1 ; i-=1
127
+ if last_q != q:
128
+ qis[q][i].scroll_to()
129
+ last_q = q
130
+ qis[q][i].click().go
131
+
132
+
133
+ def check_selected(self, selected:str, svg_class: Union[str,Dict[str,str]]=None):
134
+ """
135
+ Check on all the given (space separated) "question.items" (in this format `N.N`, as
136
+ NUMBERS, NOT INDICES).
137
+ Test labels classes are verified, to be sure the svg will be properly formatted.
138
+ Anything that is not in the selected string must by `label.unchecked`.
139
+
140
+ @svg_class:
141
+ - If not given, use `checked` for items that should be ticked, and `unchecked` for others.
142
+ - If it is a string and the item is supposed to be ticked, the label's class must match svg_class
143
+ - If it is a dict, use the class in the corresponding value.
144
+ """
145
+ selected_ = { tuple(map(int, s.split('.'))) for s in selected.split() }
146
+ wrongs = []
147
+
148
+ qis = self.get_q_i_2D_array('label')
149
+ wrongs.extend(
150
+ f'Question {i}, item {j}: label.class (svg) should contain {target!r} but was {label.get.class_!r}.'
151
+ for i,items in enumerate(qis,1) for j,label in enumerate(items,1)
152
+ if not label.has_class(
153
+ target:='unchecked' if (i,j) not in selected_ else
154
+ 'checked' if not svg_class else
155
+ svg_class if isinstance(svg_class, str) else
156
+ svg_class[f"{i}.{j}"]
157
+ )
158
+ )
159
+ assert not wrongs, f'By indices:\n' + '\n'.join(wrongs)
160
+
161
+
162
+ def validate(self):
163
+ self.check_btn.scroll_to()
164
+ self.check_btn.click().go
165
+
166
+ def validate_if_not_yet(self):
167
+ if not self.elt.find("label.missed, label.correct, label.incorrect").exists():
168
+ self.validate()
169
+
170
+ def reset(self):
171
+ self.reset_btn.scroll_to()
172
+ self.reset_btn.click().go
173
+
174
+
175
+ def snapshot(self):
176
+ """ Take a snapshot of the ids of the questions and their items (to test shuffling). """
177
+ elements = self.elt.find(self.QUESTION_RULE + ', ' + self.ITEM_RULE)
178
+ snap = '\n'.join(
179
+ ' '*elt.has_class("py_mk_item_qcm") + elt.get.id
180
+ for elt in elements
181
+ )
182
+ return snap
@@ -0,0 +1,281 @@
1
+ import re
2
+ import datetime as dt
3
+ from contextlib import contextmanager, nullcontext
4
+ from dataclasses import dataclass, fields
5
+ from typing import Callable, Generator, List, Optional, Type, TYPE_CHECKING, Union
6
+ from selenium.webdriver.support import expected_conditions as EC
7
+ from selenium.webdriver.common.alert import Alert
8
+ from selenium.common.exceptions import UnexpectedAlertPresentException
9
+ from selenium.webdriver.common.keys import Keys
10
+
11
+ from ..selenium_query import Getter
12
+
13
+ if TYPE_CHECKING:
14
+ from ..conftest import BaseSeleniumPage
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+ @dataclass
24
+ class ButtonHolder:
25
+ cut_term: 'Button' = None
26
+ stdout_wraps: 'Button' = None
27
+
28
+ comment: 'Button' = None
29
+ two_cols: 'Button' = None
30
+ full_screen: 'Button' = None
31
+
32
+ play: 'Button' = None
33
+ check: 'Button' = None
34
+ download: 'Button' = None
35
+ upload: 'Button' = None
36
+ restart: 'Button' = None
37
+ save: 'Button' = None
38
+ zip: 'Button' = None
39
+
40
+ corr: 'Button' = None
41
+ reveal: 'Button' = None
42
+
43
+
44
+ def __post_init__(self):
45
+ # Setup delays strategies + archive the field name on the Button instance:
46
+ for f in fields(self.__class__):
47
+ btn: Optional[Button] = getattr(self, f.name)
48
+ if btn:
49
+ btn.name = f.name
50
+
51
+ if f.name in ('restart',):
52
+ btn.delay = lambda d : d.switch_to.alert # does not work properly è> to avoid
53
+
54
+ elif f.name in ('play','check','corr', 'save', 'zip'):
55
+ btn.delay = None # automatically wait for the execution flag
56
+
57
+ def __iter__(self) -> Generator['Button',None,None]:
58
+ return ( getattr(self, f.name) for f in fields(self.__class__))
59
+
60
+
61
+ @classmethod
62
+ def build_buttons(cls, runner:'Runner', *rules:str, expected=-1, in_page=False, **kw):
63
+ if kw and rules:
64
+ raise ValueError(
65
+ "Cannot create Buttons using both *rules and **kw arguments. Use one or the other."
66
+ )
67
+
68
+ if rules:
69
+ found = [runner.elt.find(rule, in_page=in_page) for rule in rules]
70
+ if expected<0:
71
+ expected = len(fields(cls))
72
+ if len(found)!=expected:
73
+ raise ValueError(
74
+ f"Invalid ButtonHolder definition: found {len(found)} buttons but should be {expected}"
75
+ )
76
+ return cls(*map(Button, [runner]*len(found), found))
77
+
78
+ else:
79
+ return cls(**{name: Button(runner, runner.elt.find(rule, in_page=in_page)) for name,rule in kw.items() })
80
+
81
+
82
+
83
+
84
+
85
+ @dataclass
86
+ class Button:
87
+ runner: 'Runner'
88
+ elt: 'Getter'
89
+
90
+ delay: Union[int,None,Callable] = 0.1
91
+
92
+ name: str=None # Defined automatically from ButtonHolder
93
+
94
+ def click(self, extra_delay=None, *, prompt_before:str=None):
95
+ """
96
+ If the button is providing a prompt/alert, the selenium alert element is returned
97
+ """
98
+ ctx = self.runner.wait_executions_done(prompt_before=prompt_before) if self.delay is None else nullcontext()
99
+
100
+ with ctx:
101
+ self.elt.scroll_to().click().go
102
+
103
+ if callable(self.delay):
104
+ alert: Alert = self.elt.wait_until(self.delay)
105
+ return alert
106
+
107
+ elif self.delay:
108
+ self.elt.pause(self.delay).go
109
+
110
+ if extra_delay is not None:
111
+ self.elt.pause(extra_delay).go
112
+ return self
113
+
114
+
115
+ def check_tip_text(self, exp, contains=False, floating=False):
116
+ self.elt.scroll_to().move_to().go
117
+ elt = self.elt.find("#floating-tip", in_page=True) if floating else self.elt
118
+ if contains:
119
+ elt.check.has_text(exp)
120
+ else:
121
+ elt.check.text(exp)
122
+
123
+
124
+
125
+
126
+
127
+
128
+
129
+ @dataclass(repr=False)
130
+ class Runner:
131
+
132
+ elt: Getter
133
+
134
+ _js_runner: str = None
135
+ """
136
+ CONFIG.objs[self._js_runner] gives access to the current Runner in js (if exists).
137
+ """
138
+
139
+ def __post_init__(self):
140
+ # Works for both Ides and isolated terminals:
141
+ self._runner_id = self.elt.get.id.replace('global_','')
142
+ self._js_runner = f"CONFIG.objs.{ self._runner_id }"
143
+
144
+ def __repr__(self):
145
+ return f'{ self.__class__.__name__ }(class={self.elt.get.class_!r})'
146
+
147
+ @classmethod
148
+ def from_page(cls, page: Type['BaseSeleniumPage'] ):
149
+ from .terminal import Terminal
150
+ from .ide import Ide
151
+
152
+ # Suppress all the f... labels in jQuery.terminals
153
+ page.driver.execute_script("$('div.terminal-wrapper label, div.terminal-wrapper > svg').remove()")
154
+
155
+ runners: List[Runner] = []
156
+ for runner in page.find(".py_mk_ide, .term_solo, .py_mk_py_btn, .py_mk_auto_run"):
157
+ if runner.has_class('py_mk_ide'):
158
+ obj = Ide(runner)
159
+ elif runner.has_class('term_solo'):
160
+ obj = Terminal(runner)
161
+ elif runner.has_class('py_mk_py_btn'):
162
+ obj = PyBtn(runner)
163
+ elif runner.has_class('py_mk_auto_run'):
164
+ obj = AutoRun(runner)
165
+ else:
166
+ raise ValueError(f'Unknown runner class: { runner.get.class_ }')
167
+ runners.append(obj)
168
+ return runners
169
+
170
+
171
+ def run_js(self, code:str, *args, async_=False, with_promise=True):
172
+ """
173
+ Use driver.execute_script on the given code.
174
+ `@code` may be a string template where any `{js_runner}` will be replaced by the access
175
+ to the JS runner instance.
176
+
177
+ * `@args` are arguments that will be passed in the JS environment (available through the
178
+ `arguments` array).
179
+ * `@async_=False`: if True, JS script will run async?. An error is raised if `with_promise`
180
+ is False and `done` is not present in the code (a call to it is needed to resume execution).
181
+ * `@with_promise=True`: if True and the script is supposed to be run async, `code` is
182
+ expected to be a Promise (aka, no await in the source!) and `'.then(done, console.error)'`
183
+ is automatically added at the end of `code` to consume the promise and resume executions.
184
+ In that case, the `done` call is not needed in the source code.
185
+ """
186
+ # Escape all curly brackets for str.format...:
187
+ code = re.sub('([{}])', r'\1\1', code)
188
+ # But always restore '{{js_runner}}'!
189
+ code = code.replace('{{js_runner}}', '{js_runner}')
190
+
191
+ return self.elt.run_js(
192
+ code.format(js_runner=self._js_runner), *args, async_=async_, with_promise=with_promise,
193
+ )
194
+
195
+
196
+ @contextmanager
197
+ def wait_executions_done(self, timeout=3, *, prompt_before:str=None):
198
+ """
199
+ Generic logistic to wait for some JS Runner executions, with a default timeout at 3s.
200
+
201
+ 1. Setup `js_runner.seleniumRunningFlag` to true value
202
+ 2. Gives back control to the caller
203
+ 3. Waits for `js_runner.seleniumRunningFlag` to become false again
204
+ 4. Send back control to the caller.
205
+ """
206
+ start = dt.datetime.now()
207
+
208
+ # "Enforce" synchronization (rather, "make it less worse"):
209
+ # -> do not allow a test to start if the current runner is not done yet...
210
+ self._wait_until_runner_ready(start, timeout, 'setup')
211
+
212
+ self.run_js('{js_runner}.seleniumRunningFlag = true')
213
+ try:
214
+ yield None
215
+ # DOES NOT WORK:
216
+ # if prompt_before:
217
+ # prompt: Alert = self.elt.wait_prompt_or_alert()
218
+ # prompt.send_keys(prompt_before)
219
+ # self.elt.pause(5)
220
+ except:
221
+ # Make sure the runner always ends up in a valid state (allow to continue the tests):
222
+ self.run_js('{js_runner}.seleniumRunningFlag = false')
223
+ raise
224
+
225
+ self._wait_until_runner_ready(start, timeout, 'end')
226
+
227
+
228
+ def _wait_until_runner_ready(self, start, timeout, step):
229
+ """
230
+ Automatically wait for the underlying JsRunner to be done with the current executions,
231
+ based on the `JsRunner.seleniumRunningFlag` flag.
232
+ """
233
+ self.elt.pause(0.05).go # (seems to help with random failures...)
234
+
235
+ while self.run_js('return {js_runner}.seleniumRunningFlag'):
236
+ delta = (dt.datetime.now() - start).seconds
237
+ if delta > timeout:
238
+ assert False, f"Couldn't {step} test: { self._js_runner } stuck"
239
+ self.elt.pause(0.05).go
240
+
241
+ self.elt.pause(0.05).go # (seems to help with random failures...)
242
+
243
+
244
+ def _send_shortcut(self, keys:str, *, exec=False, target:Getter=None, pause=None):
245
+ target = target or self.elt
246
+ ctx = exec and self.wait_executions_done()
247
+ target.send_shortcut(keys, waiting_context=ctx)
248
+ if pause:
249
+ self.elt.pause(pause).go
250
+
251
+
252
+ def pause(self, sec:float):
253
+ self.elt.pause(sec).go
254
+
255
+
256
+
257
+
258
+
259
+
260
+ @dataclass(repr=False)
261
+ class PyBtn(Runner):
262
+
263
+ def __post_init__(self):
264
+ super().__post_init__()
265
+ self.button = ButtonHolder.build_buttons(self, play="button[btn_kind=py_btn]")
266
+
267
+ def check_alert_contains_on_click(self, msg:str):
268
+ try:
269
+ self.button.play.click()
270
+ assert False, "Should have raise UnexpectedAlertPresentException"
271
+ except UnexpectedAlertPresentException as e:
272
+ msg_err = str(e)
273
+ assert 'Unexpected alert dialog detected. Performed handler "dismiss"' in msg_err
274
+ assert msg in msg_err
275
+
276
+
277
+
278
+
279
+ @dataclass(repr=False)
280
+ class AutoRun(Runner):
281
+ pass
@@ -0,0 +1,71 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, List, Type, Union
3
+
4
+ from ..selenium_query import Getter
5
+
6
+ if TYPE_CHECKING:
7
+ from ..conftest import BaseSeleniumPage
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+ @dataclass
17
+ class TabbedContent:
18
+
19
+ elt: Getter
20
+
21
+ labels: Getter = None
22
+ contents: Getter = None
23
+
24
+ _label: Getter = None
25
+ _content: Getter = None
26
+
27
+ @classmethod
28
+ def from_page(cls, page: Type['BaseSeleniumPage'] ):
29
+ tabbed: List[TabbedContent] = [
30
+ TabbedContent(g) for g in page.find('div.tabbed-set')
31
+ ]
32
+ return tabbed
33
+
34
+ def __post_init__(self):
35
+ # Works for both Ides and isolated terminals:
36
+ self.labels = self.elt.find('div.tabbed-labels > label')
37
+ self.contents = self.elt.find('div.tabbed-content > div.tabbed-block')
38
+ self._content = self.contents[0]
39
+
40
+ def click(self, idx:int):
41
+ self._label = self.labels[idx]
42
+ self._label.scroll_to().click().go
43
+ self._content = self.contents[idx]
44
+ self._content.check(
45
+ "Once the label is clicked, the corresponding content div should be visible."
46
+ ).css(display='block')
47
+ return self
48
+
49
+ def _refresh(self):
50
+ i = next(i for i,tab in enumerate(self.contents) if tab.css(display='block'))
51
+ self._content = self.contents[i]
52
+ self._label = self.labels[i]
53
+
54
+ def check_visible(self, i:int):
55
+ self.contents[i].check(
56
+ f"The content div at index {i} should now be visible."
57
+ ).css(display='block')
58
+
59
+ @property
60
+ def content(self):
61
+ """ Returns the current tab's content as Getter of the top level `div`. """
62
+ self._refresh()
63
+ return self._content
64
+
65
+ @property
66
+ def label(self):
67
+ """ Returns the current tab's label as Getter of the top level `div`. """
68
+ self._refresh()
69
+ return self._label
70
+
71
+
@@ -0,0 +1,237 @@
1
+ from contextlib import contextmanager, nullcontext
2
+ from dataclasses import dataclass
3
+ import re
4
+ from typing import ClassVar, Literal, Optional, Tuple, TypeVar, Union, overload
5
+
6
+ from selenium.webdriver.common.keys import Keys
7
+
8
+ from ..selenium_query import Getter, center_of
9
+ from .runners import Runner, Button, ButtonHolder
10
+
11
+
12
+
13
+
14
+
15
+
16
+ @dataclass(repr=False)
17
+ class BaseTerminal(Runner):
18
+ """
19
+ The terminal `text` contains and extra trailing line: "\n Clipboard textarea for jQuery Terminal"
20
+ """
21
+
22
+ def __post_init__(self):
23
+ super().__post_init__()
24
+ self.terminal = self.elt if self.elt.has_class("py_mk_terminal") else self.elt.find('.py_mk_terminal')
25
+
26
+ btns_rule = '.stdout-ctrl', '.stdout-wraps-btn'
27
+ kwargs = {'expected': 2}
28
+ if self.terminal.has_class('term_solo'):
29
+ # For isolated terminals, the btns are in the parent div... (Well done, Fred... :rolleyes:)
30
+ kwargs['in_page'] = True
31
+ btns_rule = (
32
+ f"#{ self.elt.get.id } + .term_btns_wrapper > { kls }" for kls in btns_rule
33
+ )
34
+ self.button = ButtonHolder.build_buttons(self, *btns_rule, **kwargs)
35
+
36
+
37
+ def check_current_cmd(self, expected:str, msg=""):
38
+ current = self.run_js('return {js_runner}.terminal.get_command()')
39
+ assert current == expected, f'{msg + ": "*bool(msg)}{current} should be {expected}'
40
+
41
+
42
+ def get_term_content(self) -> str :
43
+ """
44
+ The current terminal content, prompt included (>>>). WARNING : empty lines are ignored.
45
+ """
46
+ return self.run_js("""
47
+ const term = {js_runner}.termWrapper[0]
48
+ const rng = document.createRange()
49
+ rng.setStartBefore(term)
50
+ rng.setEndAfter(term)
51
+ const select = window.getSelection()
52
+ select.empty()
53
+ select.addRange(rng)
54
+ return {js_runner}.getTermSelectedText()
55
+ """)
56
+ # return self.terminal.find('.terminal-wrapper').get.text.rstrip()
57
+
58
+
59
+ def check_command(self, cmd:Optional[str], expected:str, *, clear=True, contains=False):
60
+ """
61
+ Clear the current terminal (unless @clear=False), then run the given command and check
62
+ that the expected string is the terminal content (or check that it is containing the expected
63
+ string, if @contains=True).
64
+
65
+ If @cmd is None, no command is run, and the terminal is not cleared (whatever the @clear value is).
66
+ """
67
+ if clear and cmd is not None:
68
+ self.clear_terminal()
69
+ if cmd is not None:
70
+ self.exec_term_cmd(cmd)
71
+ self.check_term_content(expected, contains=contains)
72
+ return self
73
+
74
+
75
+ def check_term_content(self, expected:str, *, contains=False):
76
+ """
77
+ Clear the current terminal (unless @clear=False), then run the given command and check
78
+ that the expected string is the terminal content (or check that it is containing the expected
79
+ string, if @contains=True).
80
+
81
+ If @cmd is None, no command is run, and the terminal is not cleared (whatever the @clear value is).
82
+ """
83
+ if contains:
84
+ self.terminal.check.has_text(expected)
85
+ else:
86
+ self._assert_term_content(expected)
87
+ return self
88
+
89
+
90
+ @staticmethod
91
+ def _prepare_content(content:str):
92
+ """
93
+ Remove any trailing whitespace in actual/expected strings (because the jQuery Terminal
94
+ is just messing around, like usual...)
95
+ """
96
+ return re.sub(r' +$', '', content.rstrip(), flags=re.M)
97
+
98
+ def _assert_term_content(self, expected:str):
99
+ actual = self._prepare_content(self.get_term_content())
100
+ expected = self._prepare_content(expected)
101
+ assert actual==expected, f"Wrong terminal content.\n{actual}\n should be\n{expected}"
102
+
103
+
104
+ def exec_term_cmd(self, cmd:str):
105
+ """
106
+ Execute directly the given command (without going through UI interactions).
107
+ """
108
+ assert "\n" not in cmd, (
109
+ "Terminal commands in selenium should never be multiline (not possible to spot "
110
+ "the end of executions)"
111
+ )
112
+ cmd = cmd.replace('"', '\\"')
113
+ return self.run_js(f'{{js_runner}}.terminal.exec("{ cmd }")', async_=True)
114
+
115
+ def run_command(self, cmd:str):
116
+ """
117
+ Type the given command un the terminal and execute it by typing it in the terminal
118
+ (and pressing Enter at the end).
119
+ """
120
+ self.terminal.scroll_to().send_keys(cmd, Keys.RETURN).go
121
+ return self
122
+
123
+ def clear_terminal(self):
124
+ self.run_js("{js_runner}.terminal.clear() ; {js_runner}.terminal.set_command('')")
125
+
126
+
127
+ def terminal_shortcut(self, keys:str, *, exec=False):
128
+ self._send_shortcut(keys, exec=exec, target=self.terminal.find('textarea'))
129
+ raise NotImplementedError('verify if this works or not...')
130
+
131
+
132
+ def clean_then_type_in_terminal(self, cmd):
133
+ self.clear_terminal()
134
+ self.run_command(cmd)
135
+
136
+ def send_keys_terminal(self, *keys, execute=False):
137
+ self.terminal.scroll_to()
138
+ execute = execute or (Keys.RETURN in keys) or (Keys.ENTER in keys)
139
+ ctx = self.wait_executions_done() if execute else nullcontext()
140
+ with ctx:
141
+ self.terminal.send_keys(*keys).go
142
+
143
+
144
+
145
+
146
+
147
+ MsLang = TypeVar('ML_Lang', bound=str)
148
+ """
149
+ Mini script Language: dots separated instructions, used to accomplish various actions
150
+ through TerminalSelector.chain.
151
+
152
+ prompt.>>
153
+ cmd.-1.abcde
154
+ output.5.sort of
155
+ cmd.abcde
156
+ output.-2."Use quotes for dots..."
157
+ output.-2.'Use quotes for dots...'
158
+
159
+ If no line index is given, assume it is 0.
160
+ """
161
+
162
+ TERM_SECTIONS = "prompt","output","cmd"
163
+
164
+
165
+
166
+ @dataclass(repr=False)
167
+ class TerminalSelector(BaseTerminal):
168
+ """ Handle the logistic to use text selections through mouse actions in the terminal. """
169
+
170
+
171
+ def double_click_prompt(self):
172
+ """
173
+ Double click on the prompt (always present!), to highlight the window selection.
174
+ This won't move the cursor in the command line, if it is already set.
175
+ """
176
+ self.terminal.find("span.cmd-prompt").double_click().go
177
+
178
+
179
+ def set_cursor(self, location:Literal[0,1,-1]):
180
+ lines = self.terminal.find('.cmd-wrapper > div')
181
+ idx = location if location!=1 else lines.count() // 2
182
+ line = lines[idx]
183
+
184
+ # No need to distinguish the line currently holding the cursor from the others, even if its
185
+ # DOM layout is different: searching for `span[data-text]` will automatically linearize/simplify
186
+ # the reasoning:
187
+ chars = line.find('span[data-text]')
188
+ idx_c = location if location!=1 else chars.count() // 2
189
+ char = chars[idx_c]
190
+ char.click().go
191
+
192
+
193
+ def select(self, start_cmd:MsLang, end_cmd:MsLang):
194
+ start, i_start, chars_start = self._sanitize_cmd(start_cmd)
195
+ end, i_end, chars_end = self._sanitize_cmd(end_cmd)
196
+
197
+ # Make sure the selection will be visible by double clicking something first...
198
+ self.terminal.find('span.cmd-prompt').double_click().go
199
+
200
+ self.run_js(f"""
201
+ new window.TerminalSelector(
202
+ "#{ self._runner_id }",
203
+ [{start!r}, {i_start}, {chars_start!r}],
204
+ [{end!r}, {i_end}, {chars_end!r}]
205
+ ).makeSelection()
206
+ """)
207
+
208
+
209
+ def _sanitize_cmd(self, cmd:str):
210
+ part, *segments = cmd.split('.')
211
+ if part not in TERM_SECTIONS:
212
+ raise ValueError(
213
+ f"Invalid section name: {part} should be a member of {TERM_SECTIONS}"
214
+ )
215
+
216
+ if len(segments)==1 or not re.fullmatch(r'-?\d+', segments[0]):
217
+ idx,chars = 0, '.'.join(segments)
218
+ else:
219
+ idx,chars = int(segments[0]), '.'.join(segments[1:])
220
+ chars = chars.strip("'\"")
221
+
222
+ return part, idx, chars
223
+
224
+
225
+ def get_term_selected_text(self):
226
+ return self.run_js("return {js_runner}.getTermSelectedText()")
227
+
228
+ def check_term_selected_text(self, expected, *, show=str):
229
+ actual = self.get_term_selected_text()
230
+ assert actual==expected, f"Invalid selected text:\n{show(actual)}\n SHOULD BE:\n{show(expected)}"
231
+
232
+
233
+
234
+
235
+
236
+
237
+ class Terminal(TerminalSelector): pass
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "pmt-tools"
3
+ version = "0.1.0"
4
+ description = "Helpers for Pyodide-MkDocs-Theme selenium testing"
5
+ authors = [
6
+ {name = "Frédéric Zinelli",email = "frederic.zinelli@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.9, <4.0"
10
+ dependencies = [
11
+ "selenium-query",
12
+ "selenium (>=4.36)"
13
+ ]
14
+
15
+
16
+ [build-system]
17
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
18
+ build-backend = "poetry.core.masonry.api"