portacode 1.3.32__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl
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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/cli.py +119 -14
- portacode/connection/client.py +127 -8
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +301 -4
- portacode/connection/handlers/__init__.py +10 -1
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +674 -17
- portacode/connection/handlers/project_aware_file_handlers.py +11 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
- portacode/connection/handlers/project_state/git_manager.py +139 -572
- portacode/connection/handlers/project_state/handlers.py +28 -14
- portacode/connection/handlers/project_state/manager.py +226 -101
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/session.py +465 -84
- portacode/connection/handlers/system_handlers.py +140 -8
- portacode/connection/handlers/tab_factory.py +1 -47
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/terminal.py +51 -10
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/pairing.py +103 -0
- portacode/static/js/utils/ntp-clock.js +170 -79
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +45 -131
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/METADATA +71 -3
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- test_modules/test_device_online.py +1 -1
- test_modules/test_login_flow.py +8 -4
- test_modules/test_play_store_screenshots.py +294 -0
- testing_framework/.env.example +4 -1
- testing_framework/core/playwright_manager.py +63 -9
- portacode-1.3.32.dist-info/RECORD +0 -70
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/top_level.txt +0 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: portacode
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.11.dev0
|
|
4
4
|
Summary: Portacode CLI client and SDK
|
|
5
5
|
Home-page: https://github.com/portacode/portacode
|
|
6
6
|
Author: Meena Erian
|
|
7
7
|
Author-email: hi@menas.pro
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
14
|
Classifier: Operating System :: OS Independent
|
|
11
15
|
Requires-Python: >=3.8
|
|
@@ -24,6 +28,7 @@ Requires-Dist: watchdog>=3.0
|
|
|
24
28
|
Requires-Dist: diff-match-patch>=20230430
|
|
25
29
|
Requires-Dist: Pygments>=2.14.0
|
|
26
30
|
Requires-Dist: ntplib>=0.4.0
|
|
31
|
+
Requires-Dist: importlib_resources>=6.0
|
|
27
32
|
Provides-Extra: dev
|
|
28
33
|
Requires-Dist: black; extra == "dev"
|
|
29
34
|
Requires-Dist: flake8; extra == "dev"
|
|
@@ -85,6 +90,33 @@ Once connected, you can:
|
|
|
85
90
|
- Monitor system status
|
|
86
91
|
- Access your development environment from any device
|
|
87
92
|
|
|
93
|
+
Want to see Portacode running inside containers or powering classrooms? Browse the [`examples/` directory](https://github.com/portacode/portacode/tree/master/examples) (also bundled in the PyPI source) for copy-paste Docker Compose setups ranging from a single-device sandbox to a ten-seat workshop fleet.
|
|
94
|
+
|
|
95
|
+
## 🔑 Pair Devices with Zero-Touch Codes
|
|
96
|
+
|
|
97
|
+
The fastest way to bring a new machine online is with a short-lived pairing code:
|
|
98
|
+
|
|
99
|
+
1. Log in to [https://portacode.com](https://portacode.com) and press **Pair Device** on the dashboard:
|
|
100
|
+

|
|
101
|
+
2. A four-digit code appears (valid for 15 minutes). This code only authorizes a pairing **request**—no device can reach your account until you approve it.
|
|
102
|
+
3. On the device, run Portacode with the code:
|
|
103
|
+
```bash
|
|
104
|
+
PORTACODE_PAIRING_CODE=1234 portacode connect \
|
|
105
|
+
--device-name "My Laptop" \
|
|
106
|
+
--project-path /srv/project-one \
|
|
107
|
+
--project-path /srv/project-two
|
|
108
|
+
```
|
|
109
|
+
- `--device-name` (or `PORTACODE_DEVICE_NAME`) pre-fills the friendly label shown in the dashboard.
|
|
110
|
+
- Repeat `--project-path /abs/path` to register up to ten Projects automatically once the request is approved.
|
|
111
|
+
- Automating inside Docker? Export your own `PORTACODE_PROJECT_PATHS=/srv/a:/srv/b` and convert it into repeated `--project-path` switches before invoking the CLI—see `portacode_for_school/persistent_workspace/entrypoint.sh` for a reference implementation.
|
|
112
|
+
4. Because the device has no fingerprint yet, the CLI bootstraps an in-memory keypair and announces a pending request to the dashboard. You immediately see the card with the supplied metadata:
|
|
113
|
+

|
|
114
|
+
5. Click **Approve**. The CLI persists the keypair on disk and transitions into a normal authenticated connection. Future `portacode connect` runs reuse the stored RSA keys—no additional codes required unless you revoke the device.
|
|
115
|
+
|
|
116
|
+
Need to pair multiple machines at once? A single pairing code can be reused concurrently: every device that launches `portacode connect` with that code shows up as its own approval card until you accept or decline it.
|
|
117
|
+
|
|
118
|
+
This workflow works great for headless setups and containers: export the environment variables, run `portacode connect --non-interactive`, and finish the approval from the dashboard.
|
|
119
|
+
|
|
88
120
|
## 💡 Use Cases
|
|
89
121
|
|
|
90
122
|
- **Remote Development**: Code, build, and debug from anywhere - even your phone
|
|
@@ -186,11 +218,47 @@ sudo apt-get install xclip
|
|
|
186
218
|
```
|
|
187
219
|
|
|
188
220
|
### Key Management
|
|
189
|
-
|
|
221
|
+
Portacode follows the OS-specific *user data* directory (via [`platformdirs`](https://pypi.org/project/platformdirs/)) and keeps its identity in `portacode/keys/`:
|
|
190
222
|
- **Linux**: `~/.local/share/portacode/keys/`
|
|
191
223
|
- **macOS**: `~/Library/Application Support/portacode/keys/`
|
|
192
224
|
- **Windows**: `%APPDATA%\portacode\keys\`
|
|
193
225
|
|
|
226
|
+
When `PORTACODE_PAIRING_CODE` is set, the CLI generates an in-memory keypair, waits for dashboard approval, and only then writes the files to this directory. If that folder disappears, the CLI will create a fresh identity next time it runs.
|
|
227
|
+
|
|
228
|
+
#### Persisting Keys in Containers
|
|
229
|
+
Docker images (including the simple `python:3.11-slim` example that runs Portacode as `root`) store the data inside `/root/.local/share/portacode`. Bind-mount that path or override `XDG_DATA_HOME` so the keys survive container restarts:
|
|
230
|
+
|
|
231
|
+
```yaml
|
|
232
|
+
services:
|
|
233
|
+
device-01:
|
|
234
|
+
build: .
|
|
235
|
+
environment:
|
|
236
|
+
PORTACODE_PAIRING_CODE: "${PORTACODE_PAIRING_CODE:-}"
|
|
237
|
+
volumes:
|
|
238
|
+
- ./data/device-01/workspace:/root/workspace
|
|
239
|
+
- ./data/device-01/.local/share/portacode:/root/.local/share/portacode # persists device keys
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Alternatively, set `XDG_DATA_HOME=/root/.portacode` before running `portacode connect` and mount that directory from the host. The rule of thumb: **persist whichever folder contains `.local/share/portacode/keys/`** so your device fingerprint sticks around.
|
|
243
|
+
|
|
244
|
+
#### Minimal Docker Example
|
|
245
|
+
If you want a plug-and-play container, check the `examples/simple_device/` folder that ships with this repo and the PyPI source distribution. It contains a tiny `Dockerfile` and `docker-compose.yaml` you can copy as-is. The Dockerfile installs `git` before `pip install portacode` so GitPython can interact with repositories—remember to do the same in your own images if you expect to work inside version-controlled projects.
|
|
246
|
+
|
|
247
|
+
The accompanying Compose file demonstrates how to:
|
|
248
|
+
- run `portacode connect --non-interactive` with a predefined `--device-name` and `--project-path`
|
|
249
|
+
- pass `PORTACODE_PAIRING_CODE` via environment variables
|
|
250
|
+
- bind-mount your workspace plus `/root/.local/share/portacode` for key persistence
|
|
251
|
+
|
|
252
|
+
Together, those 10 lines illustrate the complete flow for remotely accessing a Docker-hosted machine with Portacode.
|
|
253
|
+
|
|
254
|
+
#### Workshop Fleet Example
|
|
255
|
+
Training a group? `examples/workshop_fleet/` spins up ten identical containers—one per student—with their own workspace bind mounts plus a shared read-only `instructions/` folder. The Dockerfile in that folder copies everything from `initial_content/` into the image (`COPY initial_content/ /opt/initial_content/`), and the compose command seeds each student workspace on boot via `cp -an /opt/initial_content/. /root/workspace/`. That means:
|
|
256
|
+
- Instructors drop starter code into `initial_content/` before `docker compose up` and every container gets the same seed files without overwriting student changes after the first sync.
|
|
257
|
+
- The host `instructions/` directory is mounted at `/root/workspace/instructions` in **read-only** mode, so you can update agendas or hints live while students can only view them.
|
|
258
|
+
- Each seat persists its Portacode identity in `data/student-XX/.local/share/portacode`, so reconnecting after a restart does not need new pairing codes.
|
|
259
|
+
|
|
260
|
+
See the full walkthrough and assets in [`examples/workshop_fleet/`](https://github.com/portacode/portacode/tree/master/examples/workshop_fleet), which is also shipped inside the PyPI source tarball for offline access.
|
|
261
|
+
|
|
194
262
|
## 🌱 Early Stage Project
|
|
195
263
|
|
|
196
264
|
**Portacode is a young project with big dreams.** We're building the future of remote development and mobile-first coding experiences. As a new project, we're actively seeking:
|
|
@@ -221,7 +289,7 @@ Check out our [GitHub repository](https://github.com/portacode/portacode) to get
|
|
|
221
289
|
|
|
222
290
|
## 📄 License
|
|
223
291
|
|
|
224
|
-
MIT License - see [LICENSE](LICENSE) file for details.
|
|
292
|
+
MIT License - see [LICENSE](https://github.com/portacode/portacode/blob/master/LICENSE) file for details.
|
|
225
293
|
|
|
226
294
|
---
|
|
227
295
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
|
+
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
|
+
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
+
portacode/_version.py,sha256=V3stafZrckfijvIH49elVCm8cQ_x4AD5VnTvqB60X_0,719
|
|
5
|
+
portacode/cli.py,sha256=R6BkYM-o2MCUETk-o2h-U_24E96EbVmQpIXJJY2H08Y,20387
|
|
6
|
+
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
|
+
portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
|
|
8
|
+
portacode/logging_categories.py,sha256=9m-BYrjyHh1vjZYBQT4JhAh6b_oYUhIWayO-noH1cSE,5063
|
|
9
|
+
portacode/pairing.py,sha256=OzSuc0GhrknrDrny4aBU6IUnmKzRDTtocuDpyaVnyrs,3116
|
|
10
|
+
portacode/service.py,sha256=p-HHMOAl20QsdcJydcZ74Iqes-wl8G8HItdSim30pUk,16537
|
|
11
|
+
portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJg,883
|
|
12
|
+
portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
|
|
13
|
+
portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
|
|
14
|
+
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
15
|
+
portacode/connection/terminal.py,sha256=UpH3FywKSY2l07k76sa4B3n31ecb4Jm5QsvkTZPGCIo,44395
|
|
16
|
+
portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
|
|
17
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=De95QT1lT69moV64Qubhrxb7xE-j8lvSoD_-Svyhr9s,86690
|
|
18
|
+
portacode/connection/handlers/__init__.py,sha256=4LuqzmVtD_O7EtN6TTGZc2Co6UD2dmPCJ_6qcIKEfz8,2496
|
|
19
|
+
portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
|
|
20
|
+
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
21
|
+
portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZbQw6sF04M3dM6rUV8Q,24477
|
|
22
|
+
portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
|
|
23
|
+
portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
|
|
24
|
+
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
25
|
+
portacode/connection/handlers/proxmox_infra.py,sha256=YocSgNus0TQPOEPUBuuXy-crhyCfpPZV3Mhlwo7iwQQ,10903
|
|
26
|
+
portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
|
|
27
|
+
portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
|
|
28
|
+
portacode/connection/handlers/system_handlers.py,sha256=KfmLC5WOZR7gMOrL9mf6_XLhK5VuDhzgOmOdLi0qyLw,9494
|
|
29
|
+
portacode/connection/handlers/tab_factory.py,sha256=yn93h6GASjD1VpvW1oqpax3EpoT0r7r97zFXxML1wdA,16173
|
|
30
|
+
portacode/connection/handlers/terminal_handlers.py,sha256=HRwHW1GiqG1NtHVEqXHKaYkFfQEzCDDH6YIlHcb4XD8,11866
|
|
31
|
+
portacode/connection/handlers/update_handler.py,sha256=f2K4LmG4sHJZ3LahzzoRtHBULTKkPUNwuyhwuAAg3RA,2054
|
|
32
|
+
portacode/connection/handlers/project_state/README.md,sha256=trdd4ig6ungmwH5SpbSLfyxbL-QgPlGNU-_XrMEiXtw,10114
|
|
33
|
+
portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL8r_wVs5Tlt6B9J7yQH6yQUt7gc,2541
|
|
34
|
+
portacode/connection/handlers/project_state/file_system_watcher.py,sha256=r9_UKxWTbzum0jGqxIafe68Ced2Y3xOp3ZmkpBOfRpw,8573
|
|
35
|
+
portacode/connection/handlers/project_state/git_manager.py,sha256=iGQ7LYIA7uHsZHdj3HEc_LYo7S1Lqv6-AeyyMwknBPo,70027
|
|
36
|
+
portacode/connection/handlers/project_state/handlers.py,sha256=qgOSt26rxAGNxW07AoevTwDPBdxblX4J-dX-EjOKtg4,38232
|
|
37
|
+
portacode/connection/handlers/project_state/manager.py,sha256=pRMZqPOTK9YE3abNxiAbnERIJmRys673HFOEIBiKnm4,67184
|
|
38
|
+
portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
|
|
39
|
+
portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
|
|
40
|
+
portacode/link_capture/__init__.py,sha256=93LjyYDqzOimsIDBhsPibTl7tr-8DiIzyDF7JWQkE2A,1231
|
|
41
|
+
portacode/link_capture/__pycache__/__init__.cpython-311.pyc,sha256=yKwOu63AoGpmk4l-jfGnt2G2YkI54I8MppgDvq8K4_s,2037
|
|
42
|
+
portacode/link_capture/bin/elinks,sha256=VWEQlNcPazqaEJdDMbXmXJ2SLaAqN__IUBQNp8sSSto,104
|
|
43
|
+
portacode/link_capture/bin/gio-open,sha256=jOYX5fCkqWfFAY6ebJt6C1UcXISHa4iRmLePs5jU3Ko,106
|
|
44
|
+
portacode/link_capture/bin/gnome-open,sha256=oZHDhCtUWURmPry5EgkZjeJWZC2f10Qk101Dc4fNTVs,108
|
|
45
|
+
portacode/link_capture/bin/gvfs-open,sha256=6lYC7RZJIQGwvGz7cCLPFkDTf29TNS3xuPJsSxR1kbs,107
|
|
46
|
+
portacode/link_capture/bin/kde-open,sha256=9tz3N3horN_SCxl1sE8jAQ2OVy004Ebtl8llKz-mz70,106
|
|
47
|
+
portacode/link_capture/bin/kfmclient,sha256=wkhcZdZFJ161Nua8mGZ-wtCAtzgLkqycR5Ait7Wus-g,107
|
|
48
|
+
portacode/link_capture/bin/link_capture_exec.sh,sha256=bFyyuR7z7sEq-laOn9PLje5eoCLXNhgQvTrBLy3jKRg,234
|
|
49
|
+
portacode/link_capture/bin/link_capture_wrapper.py,sha256=wvfCx2Urjzv5vNO1_58AAe4kKx2kVsmn9OoJwr6bnYI,2042
|
|
50
|
+
portacode/link_capture/bin/links,sha256=BD3tioDtCXD-8KRcLEOaF53T4y9CWIZ8ANasDGy0mic,103
|
|
51
|
+
portacode/link_capture/bin/links2,sha256=2tD7Iol-CyRJQdKs1VoNfRgaiui2mPsqcD9NolAr5_I,104
|
|
52
|
+
portacode/link_capture/bin/lynx,sha256=FbiePbYRM1ozi1xP779ku05W2PwgKie_oRfj1KqrT3E,102
|
|
53
|
+
portacode/link_capture/bin/mate-open,sha256=mL-sj3LVpzN-7Vj6gwFY8TMEG5kiRqyjwkdhMy5GRQQ,107
|
|
54
|
+
portacode/link_capture/bin/netsurf,sha256=Z308xsqfJOwZGdsE3atZXdeJoGl1pAEzoaX4jSMPALg,105
|
|
55
|
+
portacode/link_capture/bin/sensible-browser,sha256=oKVTZM1Z_mLEI2M-Lm-eaBK-Uqc00kwsa69qLhmx-kQ,114
|
|
56
|
+
portacode/link_capture/bin/w3m,sha256=mdM0AF4h_ElkfI2YZ-Ko1KHgWk2zLQrRwb2w1QQxXU8,101
|
|
57
|
+
portacode/link_capture/bin/x-www-browser,sha256=3RhqrunuuVktUf6WrbozW7qKlvfIlwXShl8PwF6Q80k,111
|
|
58
|
+
portacode/link_capture/bin/xdg-open,sha256=GLUv5ejdN8tym4PNtgKqTcZpF7YHIO3BnlWs3z3SQ3U,106
|
|
59
|
+
portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc,sha256=8NVUUJ1njsjCqZrZ2HPDmweauJQQ2W_9OWBC8Hi_o74,6049
|
|
60
|
+
portacode/static/js/test-ntp-clock.html,sha256=bUow9sifIuLNPqKvuPbpQozmEE6RhdCI4Plib3CqUmw,2130
|
|
61
|
+
portacode/static/js/utils/ntp-clock.js,sha256=t9moJyAGGU054BVtuGuSETVstlj1AoxVy44i4y9QWSs,6940
|
|
62
|
+
portacode/utils/NTP_ARCHITECTURE.md,sha256=WkESTbz5SNAgdmDKk3DrHMhtYOPji_Kt3_a9arWdRig,3894
|
|
63
|
+
portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,33
|
|
64
|
+
portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
|
|
65
|
+
portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
|
|
66
|
+
portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
|
|
67
|
+
portacode-1.4.11.dev0.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
68
|
+
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
69
|
+
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
70
|
+
test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
|
|
71
|
+
test_modules/test_file_operations.py,sha256=KXbh9t8Fah1jZp1pEPlU4_F06iJIJr2fR-yYc4RL6m8,38372
|
|
72
|
+
test_modules/test_git_status_ui.py,sha256=A_qkt-0lFLwxdr7t6YQaM0HqUElDwlZi84mlngg11RA,18734
|
|
73
|
+
test_modules/test_login_flow.py,sha256=LyKAgd6jkhO7cvy2zgGtAuUTgkD2G4otS_1hZ6O2jZo,1882
|
|
74
|
+
test_modules/test_navigate_testing_folder.py,sha256=-1EXceUEwof_sYp5paMWUNT3mAv5aIpYJ65_vqFbZew,18233
|
|
75
|
+
test_modules/test_play_store_screenshots.py,sha256=4t9EdB-BjIhAj2EA7yHchsiI3Z5bqmIGI26Dzebru2s,11094
|
|
76
|
+
test_modules/test_terminal_buffer_performance.py,sha256=YQeDDZVnsQD3ug6udKUZH3NR7PHGP75uZsLZJYya7jg,12183
|
|
77
|
+
test_modules/test_terminal_interaction.py,sha256=AxLb63oKhNLjKrny4hBj4hhFhrmHZ5UGStYDA0KzA0w,3163
|
|
78
|
+
test_modules/test_terminal_loading_race_condition.py,sha256=PsGF8QzWeNNv6G7Fda6kETcBUcXyg_vRYeD-hDHAhCo,4158
|
|
79
|
+
test_modules/test_terminal_start.py,sha256=y3IqG54UfMk-pAQ_fn5LuoM3kki6xRm11oB5AzfC-iE,1978
|
|
80
|
+
testing_framework/.env.example,sha256=zGchLcB-p22YUUCU0JIyHLduLpDuFy8c5xPacctHvfY,708
|
|
81
|
+
testing_framework/README.md,sha256=7o04mS2siNDuHA1UBh3Uu6XCbGomKjgb8gfl8YbClhE,9662
|
|
82
|
+
testing_framework/__init__.py,sha256=safHXo_xBMwAwfiF_5rx0xGcPGfpBSOgkMZx04uj4No,575
|
|
83
|
+
testing_framework/cli.py,sha256=ZHO37QO2IqZpC9VovrAYur2Vfc2AYeDqzi9Nb4lIA-w,13434
|
|
84
|
+
testing_framework/requirements.txt,sha256=VeKSPyqS4MWwLhr0Upu7fImlXZQQjtL8uYeyHOjkYaE,249
|
|
85
|
+
testing_framework/core/__init__.py,sha256=8AJQgqSCa9WgwkQNH_wTsA3JmJ4d4FRCweI-ioDgcNI,40
|
|
86
|
+
testing_framework/core/base_test.py,sha256=0kKQDNCdAJyTQfJiMBzx9_2MMRrmaVfQF0cawhvian4,13149
|
|
87
|
+
testing_framework/core/cli_manager.py,sha256=LDH_tWn-CmO08U_rmBIPpN_O6HLaQKRjdnfKGrtqs8Y,6991
|
|
88
|
+
testing_framework/core/hierarchical_runner.py,sha256=tCeksh2cXbRspurSiE-mQM1M1BOPeY8mKFbjvaBTVHw,26401
|
|
89
|
+
testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-iI5MSPS2P7zZAmk,22249
|
|
90
|
+
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
91
|
+
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
92
|
+
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
93
|
+
portacode-1.4.11.dev0.dist-info/METADATA,sha256=XXzgUM_MnhswA3L7SOeRKjeG64Si7CWErIWs_atPhtY,13051
|
|
94
|
+
portacode-1.4.11.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
95
|
+
portacode-1.4.11.dev0.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
96
|
+
portacode-1.4.11.dev0.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
97
|
+
portacode-1.4.11.dev0.dist-info/RECORD,,
|
test_modules/test_login_flow.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Login flow test - simplified and fast."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
|
|
3
5
|
from testing_framework.core.base_test import BaseTest, TestResult, TestCategory
|
|
4
6
|
|
|
5
7
|
|
|
@@ -28,9 +30,11 @@ class LoginFlowTest(BaseTest):
|
|
|
28
30
|
assert_that.status_ok(response, "Dashboard request")
|
|
29
31
|
assert_that.url_contains(page, "/dashboard", "Dashboard URL")
|
|
30
32
|
|
|
31
|
-
# Verify we have active sessions
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
# Verify we have active sessions unless explicitly allowed to skip
|
|
34
|
+
allow_empty_sessions = os.getenv("ALLOW_EMPTY_SESSIONS", "false").lower() in ("1", "true", "yes")
|
|
35
|
+
if not allow_empty_sessions:
|
|
36
|
+
active_sessions = self.inspect().get_active_sessions()
|
|
37
|
+
assert_that.is_true(len(active_sessions) > 0, "Should have active sessions")
|
|
34
38
|
|
|
35
39
|
if assert_that.has_failures():
|
|
36
40
|
return TestResult(self.name, False, assert_that.get_failure_message())
|
|
@@ -43,4 +47,4 @@ class LoginFlowTest(BaseTest):
|
|
|
43
47
|
|
|
44
48
|
async def teardown(self):
|
|
45
49
|
"""Teardown for login test."""
|
|
46
|
-
pass
|
|
50
|
+
pass
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Play Store screenshot tests for phone and tablet layouts."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
from testing_framework.core.base_test import BaseTest, TestResult, TestCategory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEVICE_LABEL = os.getenv("PLAY_STORE_DEVICE_LABEL", "Workshop Seat 01")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlayStoreScreenshotLogic:
|
|
13
|
+
"""Shared workflow for capturing dashboard and editor screenshots."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.device_label = DEVICE_LABEL
|
|
17
|
+
self.device_name = os.getenv("SCREENSHOT_DEVICE_NAME", "default")
|
|
18
|
+
self.dashboard_zoom = float(os.getenv("SCREENSHOT_ZOOM", "1.0"))
|
|
19
|
+
|
|
20
|
+
async def apply_zoom(self, page):
|
|
21
|
+
if self.dashboard_zoom != 1.0:
|
|
22
|
+
percent = int(self.dashboard_zoom * 100)
|
|
23
|
+
await page.evaluate(
|
|
24
|
+
f"document.body.style.zoom='{percent}%'"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
async def capture(self, test_instance, post_editor_steps=None) -> TestResult:
|
|
28
|
+
page = test_instance.playwright_manager.page
|
|
29
|
+
base_url = test_instance.playwright_manager.base_url
|
|
30
|
+
|
|
31
|
+
# Ensure dashboard is loaded
|
|
32
|
+
await page.goto(urljoin(base_url, "/dashboard/"))
|
|
33
|
+
await page.wait_for_load_state("networkidle")
|
|
34
|
+
|
|
35
|
+
# Locate specific device card and ensure it's online
|
|
36
|
+
device_card = (
|
|
37
|
+
page.locator(".device-card.online")
|
|
38
|
+
.filter(has_text=self.device_label)
|
|
39
|
+
.first
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
await device_card.wait_for(timeout=10000)
|
|
43
|
+
except Exception:
|
|
44
|
+
return TestResult(
|
|
45
|
+
test_instance.name,
|
|
46
|
+
False,
|
|
47
|
+
f"Device '{self.device_label}' is not online or not visible",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Scroll past navbar and capture dashboard screenshot
|
|
51
|
+
await page.evaluate("window.scrollTo(0, 66)")
|
|
52
|
+
await page.wait_for_timeout(500)
|
|
53
|
+
await test_instance.playwright_manager.take_screenshot(
|
|
54
|
+
f"{self.device_name}_dashboard"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Open the editor from this device card
|
|
58
|
+
editor_button = device_card.get_by_text("Editor")
|
|
59
|
+
await editor_button.wait_for(timeout=5000)
|
|
60
|
+
await editor_button.scroll_into_view_if_needed()
|
|
61
|
+
await page.wait_for_timeout(200)
|
|
62
|
+
try:
|
|
63
|
+
await editor_button.click(force=True)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
return TestResult(
|
|
66
|
+
test_instance.name,
|
|
67
|
+
False,
|
|
68
|
+
f"Failed to open editor for {self.device_label}: {exc}",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Select the first project in the modal
|
|
72
|
+
await page.wait_for_selector("#projectSelectorModal.show", timeout=10000)
|
|
73
|
+
await page.wait_for_selector(".item.project", timeout=10000)
|
|
74
|
+
first_project = page.locator(".item.project").first
|
|
75
|
+
await first_project.click()
|
|
76
|
+
await page.wait_for_selector(
|
|
77
|
+
"#projectSelectorModal.show",
|
|
78
|
+
state="hidden",
|
|
79
|
+
timeout=10000,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Handle LitElement/Shadow DOM editor readiness
|
|
83
|
+
try:
|
|
84
|
+
await page.wait_for_selector("ace-editor", timeout=15000)
|
|
85
|
+
await page.wait_for_function(
|
|
86
|
+
"""
|
|
87
|
+
() => {
|
|
88
|
+
const el = document.querySelector('ace-editor');
|
|
89
|
+
if (!el) return false;
|
|
90
|
+
const shadow = el.shadowRoot;
|
|
91
|
+
if (shadow && shadow.querySelector('.ace_editor')) return true;
|
|
92
|
+
return !!el.querySelector('.ace_editor');
|
|
93
|
+
}
|
|
94
|
+
""",
|
|
95
|
+
timeout=20000,
|
|
96
|
+
)
|
|
97
|
+
except Exception:
|
|
98
|
+
test_instance.logger.warning(
|
|
99
|
+
"ACE editor shadow DOM not detected, proceeding with screenshot"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
await page.wait_for_timeout(1000)
|
|
103
|
+
await test_instance.playwright_manager.take_screenshot(
|
|
104
|
+
f"{self.device_name}_editor"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if post_editor_steps:
|
|
108
|
+
result = await post_editor_steps(page, test_instance.playwright_manager)
|
|
109
|
+
if result:
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
return TestResult(
|
|
113
|
+
test_instance.name,
|
|
114
|
+
True,
|
|
115
|
+
f"Screenshots captured for {self.device_name}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PlayStorePhoneScreenshotTest(BaseTest):
|
|
120
|
+
"""Capture phone-friendly screenshots for Play Store listing."""
|
|
121
|
+
|
|
122
|
+
def __init__(self):
|
|
123
|
+
self.logic = PlayStoreScreenshotLogic()
|
|
124
|
+
super().__init__(
|
|
125
|
+
name="play_store_phone_screenshot_test",
|
|
126
|
+
category=TestCategory.UI,
|
|
127
|
+
description="Capture phone-friendly screenshots for Play Store listing",
|
|
128
|
+
tags=["screenshots", "store", "phone"],
|
|
129
|
+
depends_on=["login_flow_test"],
|
|
130
|
+
start_url="/dashboard/",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def setup(self):
|
|
134
|
+
await self.logic.apply_zoom(self.playwright_manager.page)
|
|
135
|
+
|
|
136
|
+
async def run(self) -> TestResult:
|
|
137
|
+
return await self.logic.capture(self, self._capture_phone_views)
|
|
138
|
+
|
|
139
|
+
async def teardown(self):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
async def _capture_phone_views(self, page, manager) -> TestResult:
|
|
143
|
+
async def take(name: str):
|
|
144
|
+
await page.wait_for_timeout(500)
|
|
145
|
+
await manager.take_screenshot(f"{self.logic.device_name}_{name}")
|
|
146
|
+
|
|
147
|
+
def locator_by_text(selector: str, text: str):
|
|
148
|
+
return page.locator(selector).filter(has_text=text).first
|
|
149
|
+
|
|
150
|
+
# Explorer tab
|
|
151
|
+
explorer_tab = locator_by_text(".mobile-nav-item", "Explorer")
|
|
152
|
+
try:
|
|
153
|
+
await explorer_tab.wait_for(timeout=5000)
|
|
154
|
+
await explorer_tab.click()
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
return TestResult(self.name, False, f"Explorer tab not accessible: {exc}")
|
|
157
|
+
await take("explorer")
|
|
158
|
+
|
|
159
|
+
# Git status expansion
|
|
160
|
+
git_info = page.locator(".git-branch-info").first
|
|
161
|
+
try:
|
|
162
|
+
await git_info.wait_for(timeout=5000)
|
|
163
|
+
await git_info.click()
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
return TestResult(self.name, False, f"Git status section unavailable: {exc}")
|
|
166
|
+
await take("git_status")
|
|
167
|
+
|
|
168
|
+
# Diff button
|
|
169
|
+
diff_btn = page.locator(".git-action-btn.diff").first
|
|
170
|
+
try:
|
|
171
|
+
await diff_btn.wait_for(timeout=5000)
|
|
172
|
+
await diff_btn.click()
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
return TestResult(self.name, False, f"Diff action unavailable: {exc}")
|
|
175
|
+
await page.wait_for_timeout(1000)
|
|
176
|
+
await take("git_diff")
|
|
177
|
+
|
|
178
|
+
# Terminal tab
|
|
179
|
+
terminal_tab = locator_by_text(".mobile-nav-item", "Terminal")
|
|
180
|
+
try:
|
|
181
|
+
await terminal_tab.wait_for(timeout=5000)
|
|
182
|
+
await terminal_tab.click()
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
return TestResult(self.name, False, f"Terminal tab not accessible: {exc}")
|
|
185
|
+
await take("terminal")
|
|
186
|
+
|
|
187
|
+
# AI Chat tab
|
|
188
|
+
ai_chat_tab = locator_by_text(".mobile-nav-item", "AI Chat")
|
|
189
|
+
try:
|
|
190
|
+
await ai_chat_tab.wait_for(timeout=5000)
|
|
191
|
+
await ai_chat_tab.click()
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
return TestResult(self.name, False, f"AI Chat tab not accessible: {exc}")
|
|
194
|
+
await take("ai_chat")
|
|
195
|
+
|
|
196
|
+
# First chat item
|
|
197
|
+
chat_item = page.locator(".chat-item").first
|
|
198
|
+
try:
|
|
199
|
+
await chat_item.wait_for(timeout=5000)
|
|
200
|
+
await chat_item.click()
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
return TestResult(self.name, False, f"No AI chat history available: {exc}")
|
|
203
|
+
await take("ai_chat_thread")
|
|
204
|
+
|
|
205
|
+
return TestResult(
|
|
206
|
+
self.name,
|
|
207
|
+
True,
|
|
208
|
+
"Phone screenshots captured across Explorer, Git, Diff, Terminal, and AI Chat",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
class PlayStoreTabletScreenshotTest(BaseTest):
|
|
212
|
+
"""Capture tablet-friendly screenshots for Play Store listing."""
|
|
213
|
+
|
|
214
|
+
def __init__(self):
|
|
215
|
+
self.logic = PlayStoreScreenshotLogic()
|
|
216
|
+
super().__init__(
|
|
217
|
+
name="play_store_tablet_screenshot_test",
|
|
218
|
+
category=TestCategory.UI,
|
|
219
|
+
description="Capture tablet-friendly screenshots for Play Store listing",
|
|
220
|
+
tags=["screenshots", "store", "tablet"],
|
|
221
|
+
depends_on=["login_flow_test"],
|
|
222
|
+
start_url="/dashboard/",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def setup(self):
|
|
226
|
+
await self.logic.apply_zoom(self.playwright_manager.page)
|
|
227
|
+
|
|
228
|
+
async def run(self) -> TestResult:
|
|
229
|
+
return await self.logic.capture(self, self._capture_tablet_views)
|
|
230
|
+
|
|
231
|
+
async def teardown(self):
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
async def _capture_tablet_views(self, page, manager) -> TestResult:
|
|
235
|
+
async def click_and_wait(locator, description: str, screenshot_name: str = None):
|
|
236
|
+
try:
|
|
237
|
+
await locator.wait_for(timeout=5000)
|
|
238
|
+
await locator.click()
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
return TestResult(self.name, False, f"Failed to interact with {description}: {exc}")
|
|
241
|
+
if screenshot_name:
|
|
242
|
+
await page.wait_for_timeout(500)
|
|
243
|
+
await manager.take_screenshot(f"{self.logic.device_name}_{screenshot_name}")
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
# Helper locators
|
|
247
|
+
def divider_lid(title_text):
|
|
248
|
+
return page.locator(f'.divider-lid[title="Toggle {title_text}"]').first
|
|
249
|
+
|
|
250
|
+
def persistent_toggle(title_text):
|
|
251
|
+
return page.locator(
|
|
252
|
+
f'.persistent-toggle[title="Show {title_text}"], '
|
|
253
|
+
f'.persistent-toggle[title="Hide {title_text}"]'
|
|
254
|
+
).first
|
|
255
|
+
|
|
256
|
+
# 1. Close terminal, expand git, open diff, capture
|
|
257
|
+
result = await click_and_wait(divider_lid("Terminal"), "Terminal divider")
|
|
258
|
+
if result:
|
|
259
|
+
return result
|
|
260
|
+
git_info = page.locator(".git-branch-info").first
|
|
261
|
+
result = await click_and_wait(git_info, "Git branch info", "git_status")
|
|
262
|
+
if result:
|
|
263
|
+
return result
|
|
264
|
+
git_diff_btn = page.locator(".git-action-btn.diff").first
|
|
265
|
+
result = await click_and_wait(git_diff_btn, "Git diff button", "git_version_control")
|
|
266
|
+
if result:
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
# 2. Expand AI chat and open first chat
|
|
270
|
+
ai_chat_toggle = page.locator('.persistent-toggle.ai-chat-toggle')
|
|
271
|
+
result = await click_and_wait(ai_chat_toggle, "AI Chat toggle")
|
|
272
|
+
if result:
|
|
273
|
+
return result
|
|
274
|
+
chat_item = page.locator(".chat-item").first
|
|
275
|
+
result = await click_and_wait(chat_item, "AI chat conversation", "ai_chat_thread_tablet")
|
|
276
|
+
if result:
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
# 3. Expand terminal, collapse file explorer, capture
|
|
280
|
+
terminal_tab = page.locator('.persistent-toggle.terminal-toggle-center')
|
|
281
|
+
result = await click_and_wait(terminal_tab, "Terminal toggle")
|
|
282
|
+
if result:
|
|
283
|
+
return result
|
|
284
|
+
explorer_divider = page.locator('.divider-lid.horizontal[title="Toggle File Explorer"]').first
|
|
285
|
+
result = await click_and_wait(explorer_divider, "Explorer divider", "terminal_focus")
|
|
286
|
+
if result:
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
# 4. Collapse AI chat and expand file explorer to reset
|
|
290
|
+
return TestResult(
|
|
291
|
+
self.name,
|
|
292
|
+
True,
|
|
293
|
+
"Tablet screenshots captured across Git, diff, terminal, and AI chat views",
|
|
294
|
+
)
|
testing_framework/.env.example
CHANGED
|
@@ -15,4 +15,7 @@ TEST_HEADLESS=false # true for headless mode, false for visible browser
|
|
|
15
15
|
# Optional: Test Output Directories
|
|
16
16
|
TEST_RESULTS_DIR=test_results
|
|
17
17
|
TEST_RECORDINGS_DIR=test_recordings
|
|
18
|
-
TEST_LOGS_DIR=test_results
|
|
18
|
+
TEST_LOGS_DIR=test_results
|
|
19
|
+
|
|
20
|
+
# Automation testing token (used by the testing framework to bypass captcha. Same token must be defined in ../main.env)
|
|
21
|
+
TEST_RUNNER_BYPASS_TOKEN=same-as-in-main-env
|
|
@@ -8,6 +8,7 @@ import logging
|
|
|
8
8
|
import json
|
|
9
9
|
import time
|
|
10
10
|
from datetime import datetime
|
|
11
|
+
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
try:
|
|
13
14
|
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
|
@@ -71,6 +72,13 @@ class PlaywrightManager:
|
|
|
71
72
|
env_headless = os.getenv('TEST_HEADLESS', 'false').lower() in ('true', '1', 'yes')
|
|
72
73
|
env_video_width = int(os.getenv('TEST_VIDEO_WIDTH', '1920'))
|
|
73
74
|
env_video_height = int(os.getenv('TEST_VIDEO_HEIGHT', '1080'))
|
|
75
|
+
env_viewport_width = os.getenv('TEST_VIEWPORT_WIDTH')
|
|
76
|
+
env_viewport_height = os.getenv('TEST_VIEWPORT_HEIGHT')
|
|
77
|
+
env_device_scale = os.getenv('TEST_DEVICE_SCALE_FACTOR')
|
|
78
|
+
env_is_mobile = os.getenv('TEST_IS_MOBILE')
|
|
79
|
+
env_has_touch = os.getenv('TEST_HAS_TOUCH')
|
|
80
|
+
env_user_agent = os.getenv('TEST_USER_AGENT')
|
|
81
|
+
automation_token = os.getenv('TEST_RUNNER_BYPASS_TOKEN')
|
|
74
82
|
|
|
75
83
|
# Use provided values or fall back to environment
|
|
76
84
|
self.base_url = url or env_url
|
|
@@ -124,15 +132,61 @@ class PlaywrightManager:
|
|
|
124
132
|
raise Exception(f"Failed to launch {browser_type} browser: {e}")
|
|
125
133
|
|
|
126
134
|
# Create context with recording enabled and proper viewport
|
|
135
|
+
viewport_size = {
|
|
136
|
+
"width": int(env_viewport_width) if env_viewport_width else env_video_width,
|
|
137
|
+
"height": int(env_viewport_height) if env_viewport_height else env_video_height
|
|
138
|
+
}
|
|
127
139
|
video_size = {"width": env_video_width, "height": env_video_height}
|
|
128
|
-
|
|
129
|
-
record_video_dir
|
|
130
|
-
record_video_size
|
|
131
|
-
record_har_path
|
|
132
|
-
record_har_omit_content
|
|
133
|
-
viewport
|
|
140
|
+
context_kwargs = {
|
|
141
|
+
"record_video_dir": str(self.test_recordings_dir),
|
|
142
|
+
"record_video_size": video_size,
|
|
143
|
+
"record_har_path": str(self.har_path),
|
|
144
|
+
"record_har_omit_content": False,
|
|
145
|
+
"viewport": viewport_size
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if env_device_scale:
|
|
149
|
+
try:
|
|
150
|
+
context_kwargs["device_scale_factor"] = float(env_device_scale)
|
|
151
|
+
except ValueError:
|
|
152
|
+
self.logger.warning(f"Invalid TEST_DEVICE_SCALE_FACTOR '{env_device_scale}' - ignoring")
|
|
153
|
+
|
|
154
|
+
if env_is_mobile:
|
|
155
|
+
context_kwargs["is_mobile"] = env_is_mobile.lower() in ('true', '1', 'yes')
|
|
156
|
+
|
|
157
|
+
if env_has_touch:
|
|
158
|
+
context_kwargs["has_touch"] = env_has_touch.lower() in ('true', '1', 'yes')
|
|
159
|
+
|
|
160
|
+
if env_user_agent:
|
|
161
|
+
context_kwargs["user_agent"] = env_user_agent
|
|
162
|
+
|
|
163
|
+
self.context = await self.browser.new_context(**context_kwargs)
|
|
164
|
+
self.logger.info(
|
|
165
|
+
"Viewport configured: %sx%s (device scale: %s, mobile: %s, touch: %s)",
|
|
166
|
+
viewport_size["width"],
|
|
167
|
+
viewport_size["height"],
|
|
168
|
+
context_kwargs.get("device_scale_factor", 1.0),
|
|
169
|
+
context_kwargs.get("is_mobile", False),
|
|
170
|
+
context_kwargs.get("has_touch", False),
|
|
134
171
|
)
|
|
135
|
-
|
|
172
|
+
if automation_token:
|
|
173
|
+
parsed_base = urlparse(self.base_url)
|
|
174
|
+
target_host = parsed_base.hostname
|
|
175
|
+
target_scheme = parsed_base.scheme or "http"
|
|
176
|
+
header_name = "X-Portacode-Automation"
|
|
177
|
+
|
|
178
|
+
async def automation_header_route(route, request):
|
|
179
|
+
headers = dict(request.headers)
|
|
180
|
+
parsed_request = urlparse(request.url)
|
|
181
|
+
if parsed_request.hostname == target_host and parsed_request.scheme == target_scheme:
|
|
182
|
+
headers[header_name] = automation_token
|
|
183
|
+
else:
|
|
184
|
+
headers.pop(header_name, None)
|
|
185
|
+
await route.continue_(headers=headers)
|
|
186
|
+
|
|
187
|
+
await self.context.route("**/*", automation_header_route)
|
|
188
|
+
self.logger.info("Automation bypass header restricted to same-origin requests")
|
|
189
|
+
|
|
136
190
|
self.logger.info(f"Video recording configured: {env_video_width}x{env_video_height}")
|
|
137
191
|
|
|
138
192
|
# Start tracing
|
|
@@ -178,7 +232,7 @@ class PlaywrightManager:
|
|
|
178
232
|
"""Perform login using provided credentials."""
|
|
179
233
|
try:
|
|
180
234
|
# Navigate to login page first
|
|
181
|
-
login_url = f"{self.base_url}
|
|
235
|
+
login_url = f"{self.base_url}accounts/login/"
|
|
182
236
|
await self.page.goto(login_url)
|
|
183
237
|
await self.log_action("navigate_to_login", {"url": login_url})
|
|
184
238
|
await self.take_screenshot("login_page")
|
|
@@ -463,4 +517,4 @@ class PlaywrightManager:
|
|
|
463
517
|
"actions_count": len(self.actions_log),
|
|
464
518
|
"console_logs_count": len(getattr(self, 'console_logs', [])),
|
|
465
519
|
"websocket_logs_count": len(self.websocket_logs)
|
|
466
|
-
}
|
|
520
|
+
}
|