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.

Files changed (56) hide show
  1. portacode/_version.py +2 -2
  2. portacode/cli.py +119 -14
  3. portacode/connection/client.py +127 -8
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +301 -4
  5. portacode/connection/handlers/__init__.py +10 -1
  6. portacode/connection/handlers/diff_handlers.py +603 -0
  7. portacode/connection/handlers/file_handlers.py +674 -17
  8. portacode/connection/handlers/project_aware_file_handlers.py +11 -0
  9. portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
  10. portacode/connection/handlers/project_state/git_manager.py +139 -572
  11. portacode/connection/handlers/project_state/handlers.py +28 -14
  12. portacode/connection/handlers/project_state/manager.py +226 -101
  13. portacode/connection/handlers/proxmox_infra.py +307 -0
  14. portacode/connection/handlers/session.py +465 -84
  15. portacode/connection/handlers/system_handlers.py +140 -8
  16. portacode/connection/handlers/tab_factory.py +1 -47
  17. portacode/connection/handlers/update_handler.py +61 -0
  18. portacode/connection/terminal.py +51 -10
  19. portacode/keypair.py +63 -1
  20. portacode/link_capture/__init__.py +38 -0
  21. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  22. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  23. portacode/link_capture/bin/elinks +3 -0
  24. portacode/link_capture/bin/gio-open +3 -0
  25. portacode/link_capture/bin/gnome-open +3 -0
  26. portacode/link_capture/bin/gvfs-open +3 -0
  27. portacode/link_capture/bin/kde-open +3 -0
  28. portacode/link_capture/bin/kfmclient +3 -0
  29. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  30. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  31. portacode/link_capture/bin/links +3 -0
  32. portacode/link_capture/bin/links2 +3 -0
  33. portacode/link_capture/bin/lynx +3 -0
  34. portacode/link_capture/bin/mate-open +3 -0
  35. portacode/link_capture/bin/netsurf +3 -0
  36. portacode/link_capture/bin/sensible-browser +3 -0
  37. portacode/link_capture/bin/w3m +3 -0
  38. portacode/link_capture/bin/x-www-browser +3 -0
  39. portacode/link_capture/bin/xdg-open +3 -0
  40. portacode/pairing.py +103 -0
  41. portacode/static/js/utils/ntp-clock.js +170 -79
  42. portacode/utils/diff_apply.py +456 -0
  43. portacode/utils/diff_renderer.py +371 -0
  44. portacode/utils/ntp_clock.py +45 -131
  45. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/METADATA +71 -3
  46. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  47. test_modules/test_device_online.py +1 -1
  48. test_modules/test_login_flow.py +8 -4
  49. test_modules/test_play_store_screenshots.py +294 -0
  50. testing_framework/.env.example +4 -1
  51. testing_framework/core/playwright_manager.py +63 -9
  52. portacode-1.3.32.dist-info/RECORD +0 -70
  53. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +0 -0
  54. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  55. {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/licenses/LICENSE +0 -0
  56. {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.32
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
+ ![Pair Device button](https://raw.githubusercontent.com/portacode/portacode/master/docs/images/pair-device-button.png)
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
+ ![Pairing request card](https://raw.githubusercontent.com/portacode/portacode/master/docs/images/pairing-request.png)
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
- Key files are stored in:
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,,
@@ -41,4 +41,4 @@ class DeviceOnlineTest(BaseTest):
41
41
 
42
42
  async def teardown(self):
43
43
  """Teardown for device online test."""
44
- pass
44
+ pass
@@ -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
- active_sessions = self.inspect().get_active_sessions()
33
- assert_that.is_true(len(active_sessions) > 0, "Should have active sessions")
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
+ )
@@ -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
- self.context = await self.browser.new_context(
129
- record_video_dir=str(self.test_recordings_dir),
130
- record_video_size=video_size,
131
- record_har_path=str(self.har_path),
132
- record_har_omit_content=False,
133
- viewport=video_size
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}/accounts/login/"
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
+ }