diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba1c6b80..ff496c69 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,6 @@ updates: directory: "/" # Location of package manifests schedule: interval: "daily" + # Development work, dependency bumps included, goes to dev; main takes + # releases only, and every push to main publishes to PyPI. + target-branch: "dev" diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index edd826fe..a0392514 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -31,6 +31,12 @@ jobs: run: python -m pip install -r dev_requirements.txt - name: Install test tooling run: python -m pip install pytest pytest-cov hypothesis + - name: Install prthinker for the command-line contract test + # prthinker is not on PyPI and needs Python 3.12+. One leg is enough to + # check that the arguments and environment PyBreeze builds are still the + # ones prthinker accepts (test/test_utils/test_prthinker_contract.py). + if: matrix.python-version == '3.12' + run: python -m pip install "prthinker[runner] @ https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning/archive/refs/heads/main.zip" - name: Run unit tests (pytest) run: python -m pytest test/test_utils/ -v --tb=short --cov=pybreeze --cov-branch --cov-report=xml - name: Upload coverage for analysis diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index 1c64b312..5c4766f1 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -31,6 +31,12 @@ jobs: run: python -m pip install -r requirements.txt - name: Install test tooling run: python -m pip install pytest pytest-cov hypothesis + - name: Install prthinker for the command-line contract test + # prthinker is not on PyPI and needs Python 3.12+. One leg is enough to + # check that the arguments and environment PyBreeze builds are still the + # ones prthinker accepts (test/test_utils/test_prthinker_contract.py). + if: matrix.python-version == '3.12' + run: python -m pip install "prthinker[runner] @ https://github.com/JE-Chen/Code-Review-Framework-Combining-Large-Language-Models-and-Chain-of-Thought-Reasoning/archive/refs/heads/main.zip" - name: Run unit tests (pytest) run: python -m pytest test/test_utils/ -v --tb=short --cov=pybreeze --cov-branch --cov-report=xml - name: Upload coverage for analysis diff --git a/CLAUDE.md b/CLAUDE.md index 4823b034..2a7a472a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,22 +2,30 @@ Automation-first Python IDE built on PySide6 + JEditor, integrating Web/API/GUI/Load testing into a single environment. -**This file is the only home for project rules.** Anything that constrains how work is done here — conventions, security requirements, quality gates, commit policy — belongs in this file. Do not start a `progress.md`, a scratch notes file, or any other side document to hold rules: a rule kept somewhere else is a rule nobody reads. Reference material that is not a rule (the architecture map, the plugin API) lives in its own file and is linked from here. +**This file is the only home for project rules.** Anything that constrains how work is done here — conventions, security requirements, quality gates, commit policy — belongs in this file. Do not put rules in `progress.md` (it holds outstanding work only), a scratch notes file, or any other side document: a rule kept somewhere else is a rule nobody reads. Reference material that is not a rule (the architecture map, the plugin API) lives in its own file and is linked from here. ## Architecture ``` pybreeze/ ├── __init__.py # Facade: start_editor, PyBreezeMainWindow, EDITOR_EXTEND_TAB +├── __main__.py # python -m pybreeze ├── pybreeze_ui/ # Presentation layer (PySide6) │ ├── editor_main/ # Main window (extends JEditor) + file tree context menu -│ ├── menu/ # Menu builders: automation / install / tools / plugin / dock +│ ├── menu/ # Menu builders: automation / install / tools (tabs and docks) / plugin, +│ │ # menu_utils, extend_jeditor_tab_menu (the JupyterLab tab entry) │ ├── tools_gui/ # Tool tabs: cURL, HAR, JWT, diff, regex, headers, … │ ├── diagram_editor/ # WYSIWYG diagram editor (QGraphicsScene, Mermaid import) │ ├── extend_ai_gui/ # CoT code review, prompt editors, skill send │ ├── connect_gui/ # ssh/ (terminal + SFTP tree), url/ (AI review client) │ ├── jupyter_lab_gui/ # JupyterLab tab (QWebEngineView) │ ├── show_code_window/ # CodeWindow — subprocess output display +│ ├── thread_keeper.py # let_run_out: a worker QThread outlives its closed widget; if_alive: weak slots +│ ├── gui_thread_gc.py # Garbage collected on a GUI-thread timer, never on a worker +│ ├── plain_text.py # as_text: server/file text shown in message boxes as text, not markup +│ ├── exact_text.py # exact_text: a text box read as typed (toPlainText changes U+00A0, U+2028) +│ ├── error_text.py # error_text: a tool's English error (exception_tags) in the IDE language +│ ├── closing.py # may_close / AskingDock: tabs and docks with unsaved work are asked first │ ├── dialog/ # prthinker settings dialog │ └── syntax/ # Automation keyword highlighting definitions ├── extend/ @@ -25,10 +33,10 @@ pybreeze/ │ │ ├── python_task_process_manager.py # TaskProcessManager (subprocess + threads + QTimer) │ │ ├── process_executor_utils.py # build_process / start_process / run_dir_files_* │ │ ├── file_runner_process.py # FileRunnerProcess — plugin run configs (any language) -│ │ ├── queue_pump.py # Shared per-tick queue drain +│ │ ├── queue_pump.py # Shared pipe reader + per-tick queue drain │ │ ├── api_testka/ auto_control/ web_runner/ load_density/ │ │ ├── file_automation/ mail_thunder/ # Each delegates to build_process with its package name -│ │ ├── test_pioneer/ # TestPioneerProcess (custom variant) +│ │ ├── test_pioneer/ # python -m test_pioneer -e via start_module_process │ │ └── prthinker/ # Code review via start_module_process (secrets via env) │ ├── mail_thunder_extend/ # Post-test email report hook │ └── prthinker_extend/ # prthinker settings + argument assembly (pure logic, no Qt) @@ -38,9 +46,10 @@ pybreeze/ ├── header_tools/ jwt_tools/ hash_tools/ timestamp_tools/ ├── regex_tools/ query_tools/ url_tools/ diff_tools/ ├── http_reference/ json_format/ response_inspector/ - ├── network/ # url_validation (SSRF), http_client (capped reads) + ├── network/ # url_validation (SSRF), public_http (pinned connections), http_client (capped reads) ├── exception/ # ITEException hierarchy ├── logging/ file_process/ app_dirs.py / subprocess_util.py + ├── terminal_text.py # Escape sequences and controls stripped from terminal output (SSH, run window) └── manager/package_manager/ # PackageManager — holds syntax_check_list ``` @@ -58,8 +67,9 @@ pybreeze/ ## Branching & CI -- `main`: stable, publishes `pybreeze` · `dev`: development, publishes `pybreeze_dev` -- Version config: `pyproject.toml` (stable), `dev.toml` (dev) — keep both in sync when bumping +- `main`: stable. On every push to `main`, the `publish` job in `stable.yml` bumps the patch version in `pyproject.toml`, uploads `pybreeze` to PyPI, then commits and tags the bump. `dev`: development. `dev.yml` runs the tests and SonarCloud and publishes nothing +- Never edit a version by hand: CI owns `pyproject.toml`'s, and `dev` is always behind `origin/main` +- `dev.toml` describes a `pybreeze_dev` package that no workflow builds; PyPI's `pybreeze_dev` stopped at the 1.0.14 it names. Whether CI should publish it or `dev.toml` should be deleted is undecided (workspace X-13). Until then, keep its `dependencies` identical to `pyproject.toml`'s - `unit-tests` job: GitHub Actions on Windows, Python 3.10–3.14 — install deps → pytest `test/test_utils/` → `start_automation_test` → `extend_automation_test` - `sonarcloud` job: CI-based SonarQube Cloud analysis (`sonar-project.properties`), `needs: unit-tests` so it can consume the `coverage-xml` artifact that leg uploads. Automatic Analysis is off and must stay off — the two modes are mutually exclusive and the scanner refuses to run alongside it - SonarCloud's plan for this organization exposes results for `main` and for pull requests only. An analysis pushed for another branch succeeds but its results read back 403, so `dev.yml` scans on pull requests only; `stable.yml` also scans pushes to `main`. Do not "fix" this by scanning every `dev` push — the numbers are not readable @@ -81,9 +91,11 @@ ruff check pybreeze/ # before committing non-trivia - Python 3.10+: `X | Y` unions, `from __future__ import annotations`, `TYPE_CHECKING` guard for hint-only imports - **Never update UI from a worker thread** — Queue + QTimer (see `TaskProcessManager`) or Qt Signal/Slot +- A slot on a thread (or any object) the widget keeps must not hold the widget: connect a bound method, or `thread_keeper.if_alive(weakref.ref(self), ...)`. A lambda capturing `self` there is a cycle through Qt that Python's collector cannot see, and the closed widget is never freed +- Automatic garbage collection is off in the IDE: `start_editor()` collects on a GUI-thread timer (`gui_thread_gc.py`), because a collection on a worker destroys Qt objects there. Never call `gc.enable()` - Custom exceptions inherit from `ITEException`; log via `pybreeze_logger` (lazy `%s` formatting, never `print()`) - Plugin API: `register_programming_language()` / `register_natural_language()` from `je_editor.plugins` -- A QAction built for a menu must be stored on the main window — Qt holds no reference and a GC'd action silently stops responding +- A QAction built for a menu must be kept alive: store it on the main window or give it the menu as its parent. A menu does not own the actions added to it, so one held only by a local variable is deleted when the builder returns and its entry disappears - Delete unused code immediately — no dead imports, unreachable branches, commented-out blocks, or `_old_` prefixes - Follow PEP 8 and standard Pythonic practice; `ruff` is the arbiter @@ -97,22 +109,23 @@ ruff check pybreeze/ # before committing non-trivia **Network (SSRF)** — every outbound request to a user-supplied URL must first pass validation: 1. `http://` / `https://` only — block `file://`, `ftp://`, `data:`, `gopher://` 2. Resolve the hostname and reject private / loopback / link-local / reserved IPs -3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary) -4. `allow_redirects=False`, or re-validate every redirect target +3. Enforce timeouts (15 s downloads, 30 s API calls) and response size caps (20 MB binary). A read timeout restarts with every byte, so a request that may run long also gets an overall bound: `public_http.overall_deadline()` around the request (image downloads: 120 s) and `read_capped_text`'s `max_seconds` +4. `allow_redirects=False`, or re-validate every redirect target. `public_session()` follows no redirect and leaves a 3xx unread whatever `allow_redirects` says (`requests` otherwise reads its whole body and parses its `Location` to prepare `Response.next`) +5. Connect only to the address checked: send through `public_session()` (requests) or `PublicHTTPHandler` / `PublicHTTPSHandler` (urllib) from `utils/network/public_http.py`, which check again as they connect and connect only to the addresses checked (each in turn), so a name that resolves differently after validation (DNS rebinding) gets nowhere private. `test_http_goes_through_public_connections.py` fails on a direct `requests.*` or `urlopen` call -Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. +Reference implementations: `utils/network/url_validation.py` (`validate_url`), `utils/network/public_http.py` (`public_session`), `utils/network/http_client.py` (`read_capped_text`), `diagram_editor/diagram_net_utils.py` (`safe_download_image`). Never pass a user URL to `urlopen()` / `requests.*` unvalidated, and never set `verify=False`. -**SSH** — never `paramiko.AutoAddPolicy()` or `WarningPolicy()`. Use `apply_host_key_policy(client, parent_widget)` from `connect_gui/ssh/ssh_host_key_policy.py`: it shows the SHA256 fingerprint for confirmation on first connect and persists to `~/.pybreeze/ssh_known_hosts`. +**SSH** — never `paramiko.AutoAddPolicy()` or `WarningPolicy()`. Use `apply_host_key_policy(client, parent)` from `connect_gui/ssh/ssh_host_key_policy.py`: it shows the SHA256 fingerprint for confirmation on first connect and persists to `~/.pybreeze/ssh_known_hosts`. Every `connect()` passes `disabled_algorithms=SHA1_ALGORITHMS` (`connect_gui/ssh/ssh_connect_thread.py`): `requirements.txt` does not pin paramiko, and paramiko 4 still offers SHA-1 signatures and key exchanges (CVE-2026-44405). **Subprocess** — always argument lists, explicit `shell=False`, `timeout` on every `subprocess.run()`. Never interpolate user input into a command string. Secrets travel as `env`, never argv (see `prthinker_setting.environment_for`). The IDE intentionally runs user-authored scripts — this hardening guards against accidental shell injection, not against malicious local files. -**JupyterLab** — the embedded server is localhost-only; the empty `--ServerApp.token`/`password` and `--ServerApp.disable_check_xsrf=True` are safe *only* because of that. Never change `--ServerApp.ip` to an externally reachable address. +**JupyterLab** — the embedded server is localhost-only; the empty `--ServerApp.token`/`password` and `--ServerApp.disable_check_xsrf=True` are safe *only* because of that. Never change `--ServerApp.ip` to an externally reachable address, and never set `--ServerApp.allow_origin`: a loopback bind does not stop a browser, and with the origin open any page the user visits can drive a tokenless server. The view loads from the same origin and needs nothing relaxed. The server outlives its launcher thread, so its tab stops it on close whatever the thread's state. -**File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. +**File I/O** — dialog-chosen paths are trusted; paths loaded from saved data (`.diagram.json`) are not: check `is_file()` and an extension allowlist, or run URLs through SSRF validation. Use `pathlib`, never string concatenation. Write to `~/.pybreeze/` via `app_dirs.pybreeze_data_dir()` with `encoding="utf-8"`; read through `pybreeze_data_path()`, which creates nothing. Replace a file the user would lose through `utils/file_process/replace_file.replace_text` (written beside it, then moved into place), never an in-place `write_text`; a file something else writes (an image, an SVG export) goes through `replace_written`. Resolve symlinks with `Path.resolve(strict=True)` and verify the result stays in bounds. -**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. +**Qt** — `QGraphicsTextItem` text interaction must not be on by default (double-click to edit). Text from a server or a file (a remote path, an error message, a host name, a file name) never reaches a `QMessageBox` or `QLabel` as it is: Qt reads markup in it (`Qt::AutoText`) and loads an ``. Pass it through `pybreeze_ui/plain_text.as_text()`, or give the label `Qt.TextFormat.PlainText`; `test_message_boxes_show_text.py` fails on a `QMessageBox` text that is neither a literal, a word-dict entry as it is, nor `as_text(...)`. JSON (or a generated Python string) a tool shows in a text box is written with `utils/json_format/view_safe.dumps_for_view()` / `escape_for_view()`: Qt gives U+2029, U+FDD0 and U+FDD1 back as newlines and drops a lone surrogate, so Copy and Save wrote something else. Plugin loading takes only `.py` files, skipping `_`/`.` prefixes. `QWebEngineView.setUrl()` only for localhost or user-confirmed URLs; never `setHtml()` with unsanitised content. -**Secrets** — SSH passwords and passphrases stay in memory for the session only. Password fields use `QLineEdit.EchoMode.Password`. +**Secrets** — SSH passwords and passphrases stay in memory for the session only. A secret that must persist (the prthinker keys and token) is written with `replace_text(..., private=True)` under the `0700` data folder. Password fields use `QLineEdit.EchoMode.Password`. **Dependencies** — pin exact versions in `requirements.txt` / `dev_requirements.txt`. Review any new dependency's maintenance and CVE history; prefer stdlib over a single-function package. @@ -128,6 +141,18 @@ Per function: cyclomatic and cognitive complexity ≤ 15 (hard cap 20) · ≤ 75 - No `TODO` / `FIXME` without an issue reference (`# TODO(#123): ...`) - Justify each `# noqa: RULE` with a short reason — never blanket-disable +## Stage commits, `progress.md`, `docs/updates/` and `architecture.md` + +Workspace rule shared by every repository under `D:\Codes` (full text: `D:\Codes\CLAUDE.md`). + +- **Commit at every stage.** A stage is the smallest piece of work that leaves the repository consistent and passes this project's checks (definition of done, tests, lint): one finished `progress.md` item, or one self-contained step of a larger one. Commit it before starting the next stage, before switching to another repository, and before the session ends. Do not leave work uncommitted across sessions; if a stage cannot be finished, commit the consistent part and record the rest in `progress.md`. + - Stage only the files that stage touched (`git add `, never `git add -A`), follow this file's commit-message rules, and never add AI attribution. + - Committing is not pushing: push or open a PR only as this project's branch flow says or when asked. +- **`progress.md`** (repository root, tracked) holds outstanding work only: no finished items, no history, no rules. +- **`docs/updates/`** records finished work: one batch file per month (`YYYY-MM.md`), one entry per piece of work headed `## U-YYYYMMDD-NN · date · title · #tags`, and an index with query commands in `docs/updates/README.md`. When a `progress.md` item is done, delete it and add a `#done` entry plus its index row in the same commit. +- **`architecture.md`** (repository root) is the short architecture overview: layers, entry points, main flows, extension points, cross-project boundaries. Update it in the same commit whenever a change alters any of those. `architecture_explore.md` stays the detailed per-module map under its own rule in this file. +- **Cross-project contracts** are listed in `architecture.md` §6: what other repositories rely on here (CLI flags, import paths, constructor arguments, file layouts) and what this repository relies on elsewhere. No test here protects them, so never rename or remove one without changing its consumers in the same round, and update §6 whenever a contract is added or changes. + ## Commit & PR rules - Commit messages: short imperative sentence ("Update stable version", "Fix github actions") diff --git a/PLUGIN_GUIDE.md b/PLUGIN_GUIDE.md index 23ec0751..6c53e1fd 100644 --- a/PLUGIN_GUIDE.md +++ b/PLUGIN_GUIDE.md @@ -1,314 +1,31 @@ # PyBreeze Plugin Guide / 插件開發指南 -PyBreeze (jeditor) supports external plugins for adding **syntax highlighting** and **UI translations**. +PyBreeze is built on JEditor and uses its plugin system unchanged: the `je_editor.plugins` API +(`register_programming_language`, `register_natural_language`, `PLUGIN_RUN_CONFIG`), the +`jeditor_plugins/` directory and the *Plugins → Plugin Browser* menu. There is therefore one guide +for both editors, kept in the JEditor repository: -PyBreeze (jeditor) 支援外部插件,可用於新增**語法高亮**和 **UI 翻譯**。 +**→ [JEditor `PLUGIN_GUIDE.md`](https://github.com/Integration-Automation/JEDITOR/blob/main/PLUGIN_GUIDE.md)** ---- - -## Quick Start / 快速開始 - -1. Create a `.py` file in the `jeditor_plugins/` directory (under your working directory). -2. Define a `register()` function. -3. Optionally define `PLUGIN_NAME`, `PLUGIN_AUTHOR`, `PLUGIN_VERSION` for the Plugins menu. - - - -1. 在工作目錄下的 `jeditor_plugins/` 建立一個 `.py` 檔案。 -2. 定義一個 `register()` 函式。 -3. 可選:定義 `PLUGIN_NAME`、`PLUGIN_AUTHOR`、`PLUGIN_VERSION`,會顯示在插件選單中。 - ---- - -## Plugin Metadata / 插件元資料 - -```python -PLUGIN_NAME = "My Plugin" # Display name in the Plugins menu / 插件選單顯示名稱 -PLUGIN_AUTHOR = "Your Name" # Author / 作者 -PLUGIN_VERSION = "1.0.0" # Version / 版本號 -``` - -All three are optional. If omitted, the filename is used as the plugin name. +Ready-made plugins (C, C++, Go, Java and Rust highlighting with run support, a French UI +translation) live in [IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins). -三者皆為可選。若未定義,則以檔名作為插件名稱。 +PyBreeze reads one run-config key JEditor does not: `"encoding"`, the encoding the program writes +its output in (`"cp950"`, `"utf-8"`, or `"locale"` for the machine's own). Without it the output is +read in the IDE's encoding, UTF-8 by default. Java and localized compilers write the console's code +page, so a Java run config on a non-English Windows wants `"encoding": "locale"`. --- -## Syntax Highlighting Plugin / 語法高亮插件 - -Use `register_programming_language()` to add syntax highlighting for file types. - -使用 `register_programming_language()` 為檔案類型新增語法高亮。 - -### API - -```python -from je_editor.plugins import register_programming_language - -register_programming_language( - suffix=".ext", # File extension / 副檔名 - syntax_words={...}, # Keyword groups / 關鍵字群組 - syntax_rules={...}, # Regex rules (optional) / 正則規則(可選) -) -``` - -### syntax_words format / syntax_words 格式 - -```python -from PySide6.QtGui import QColor - -syntax_words = { - "group_name": { - "words": ("keyword1", "keyword2", ...), # Tuple or set of keywords / 關鍵字元組或集合 - "color": QColor(r, g, b), # Highlight color / 高亮顏色 - }, - # More groups... -} -``` - -### syntax_rules format / syntax_rules 格式 - -```python -syntax_rules = { - "rule_name": { - "rules": (r"regex_pattern", ...), # Tuple of regex patterns / 正則表達式元組 - "color": QColor(r, g, b), # Highlight color / 高亮顏色 - }, -} -``` - -### Full Example / 完整範例 - -```python -"""Go syntax highlighting plugin.""" -from PySide6.QtGui import QColor -from je_editor.plugins import register_programming_language - -PLUGIN_NAME = "Go Syntax Highlighting" -PLUGIN_AUTHOR = "Your Name" -PLUGIN_VERSION = "1.0.0" - -go_syntax_words = { - "keywords": { - "words": ( - "break", "case", "chan", "const", "continue", - "default", "defer", "else", "fallthrough", "for", - "func", "go", "goto", "if", "import", - "interface", "map", "package", "range", "return", - "select", "struct", "switch", "type", "var", - ), - "color": QColor(86, 156, 214), - }, - "types": { - "words": ( - "bool", "byte", "complex64", "complex128", - "float32", "float64", "int", "int8", "int16", - "int32", "int64", "rune", "string", "uint", - "uint8", "uint16", "uint32", "uint64", "uintptr", - "error", "nil", "true", "false", "iota", - ), - "color": QColor(78, 201, 176), - }, -} - -go_syntax_rules = { - "single_line_comment": { - "rules": (r"//[^\n]*",), - "color": QColor(106, 153, 85), - }, -} - - -def register() -> None: - register_programming_language( - suffix=".go", - syntax_words=go_syntax_words, - syntax_rules=go_syntax_rules, - ) -``` - -### Multiple Suffixes / 多個副檔名 - -If a language uses multiple file extensions, register each suffix with the same `syntax_words`: - -若一個語言使用多個副檔名,用相同的 `syntax_words` 分別註冊每個副檔名: - -```python -def register() -> None: - for suffix in (".cpp", ".cxx", ".cc", ".h", ".hpp", ".hxx"): - register_programming_language( - suffix=suffix, - syntax_words=cpp_syntax_words, - syntax_rules=cpp_syntax_rules, - ) -``` - -They will be grouped under one submenu in the Plugins menu. - -它們會在插件選單中合併顯示在同一個子選單下。 - ---- - -## Translation Plugin / 翻譯插件 - -Use `register_natural_language()` to add a new UI language. - -使用 `register_natural_language()` 新增 UI 語言。 - -### API - -```python -from je_editor.plugins import register_natural_language - -register_natural_language( - language_key="French", # Internal key / 內部鍵值 - display_name="Francais", # Shown in Language menu / 語言選單顯示名稱 - word_dict={...}, # Translation dictionary / 翻譯字典 -) -``` - -### word_dict keys / word_dict 鍵值 - -The `word_dict` should contain the same keys as jeditor's built-in `english_word_dict`. -Common keys include: - -`word_dict` 應包含與 jeditor 內建 `english_word_dict` 相同的鍵值。 -常用鍵值包括: - -| Key | Description / 說明 | -|---|---| -| `application_name` | Window title / 視窗標題 | -| `file_menu_label` | File menu / 檔案選單 | -| `run_menu_label` | Run menu / 執行選單 | -| `tab_name_editor` | Editor tab / 編輯器分頁 | -| `language_menu_label` | Language menu / 語言選單 | -| `help_menu_label` | Help menu / 幫助選單 | - -For a complete list, refer to `je_editor.utils.multi_language.english.english_word_dict` -or see the example plugin `exe/jeditor_plugins/french.py`. - -完整鍵值列表請參考 `je_editor.utils.multi_language.english.english_word_dict`, -或參考範例插件 `exe/jeditor_plugins/french.py`。 - -### Full Example / 完整範例 - -```python -"""Japanese translation plugin.""" -from je_editor.plugins import register_natural_language - -PLUGIN_NAME = "Japanese Translation" -PLUGIN_AUTHOR = "Your Name" -PLUGIN_VERSION = "1.0.0" - -japanese_word_dict = { - "application_name": "JEditor", - "file_menu_label": "ファイル", - "run_menu_label": "実行", - "tab_name_editor": "エディタ", - "language_menu_label": "言語", - "language_menu_bar_english": "英語", - "language_menu_bar_traditional_chinese": "繁体字中国語", - "language_menu_bar_please_restart_messagebox": "アプリケーションを再起動してください", - # ... more keys -} - - -def register() -> None: - register_natural_language( - language_key="Japanese", - display_name="日本語", - word_dict=japanese_word_dict, - ) -``` - ---- - -## Run Config (Execute Files) / 執行設定 - -Plugins can register a `PLUGIN_RUN_CONFIG` to enable running files from the **Run with...** menu. - -插件可定義 `PLUGIN_RUN_CONFIG`,讓使用者可以從 **以...執行** 選單執行檔案。 - -### Interpreted Languages / 直譯式語言 - -For languages that run directly (Go, Java 11+, Python): - -直接執行的語言(Go、Java 11+、Python): - -```python -PLUGIN_RUN_CONFIG = { - "name": "Go", # Display name in menu / 選單顯示名稱 - "suffixes": (".go",), # Supported file types / 支援的副檔名 - "compiler": "go", # Executable / 執行檔 - "args": ("run",), # Args before file path / 檔案路徑前的參數 -} -# Runs: go run file.go -``` - -### Compiled Languages / 編譯式語言 - -For languages that need compile-then-run (C, C++, Rust): - -需要先編譯再執行的語言(C、C++、Rust): - -```python -PLUGIN_RUN_CONFIG = { - "name": "C (GCC)", - "suffixes": (".c",), - "compiler": "gcc", - "args": (), - "compile_then_run": True, # Compile first, then run output / 先編譯再執行 - "output_flag": "-o", # Flag for output binary / 輸出檔案的旗標 -} -# Compiles: gcc file.c -o file -# Then runs: ./file (Linux/Mac) or file.exe (Windows) -``` - -### Config Keys / 設定鍵值 - -| Key | Required | Description | -|---|---|---| -| `name` | Yes | Display name / 顯示名稱 | -| `suffixes` | Yes | Tuple of file extensions / 副檔名元組 | -| `compiler` | Yes | Compiler/interpreter executable / 編譯器或直譯器 | -| `args` | No | Extra args before file path / 檔案路徑前的額外參數 | -| `compile_then_run` | No | If `True`, compile first / 若為 `True` 則先編譯 | -| `output_flag` | No | Output file flag (default `"-o"`) / 輸出旗標 | - ---- - -## Directory Structure / 目錄結構 - -``` -working_directory/ - jeditor_plugins/ - my_syntax.py # Single-file plugin / 單檔插件 - my_language.py - my_package/ # Package plugin / 套件插件 - __init__.py -``` - -- Plugins are auto-discovered from `jeditor_plugins/` under the current working directory. -- Files starting with `_` or `.` are ignored. -- Each plugin must have a `register()` function. - - - -- 插件會從工作目錄下的 `jeditor_plugins/` 自動載入。 -- 以 `_` 或 `.` 開頭的檔案會被忽略。 -- 每個插件必須有 `register()` 函式。 - ---- +PyBreeze 建立在 JEditor 之上,原封不動地使用它的插件系統:`je_editor.plugins` API +(`register_programming_language`、`register_natural_language`、`PLUGIN_RUN_CONFIG`)、 +`jeditor_plugins/` 目錄,以及 *插件 → Plugin Browser* 選單。所以兩個編輯器共用一份指南,放在 JEditor repo: -## Existing Plugins / 現有插件 +**→ [JEditor `PLUGIN_GUIDE.md`](https://github.com/Integration-Automation/JEDITOR/blob/main/PLUGIN_GUIDE.md)** -| Plugin | File | Type | Run Support | -|---|---|---|---| -| C Syntax Highlighting | `c_syntax.py` | Syntax (`.c`) | GCC compile & run | -| C++ Syntax Highlighting | `cpp_syntax.py` | Syntax (`.cpp`, `.cxx`, `.cc`, `.h`, `.hpp`, `.hxx`) | G++ compile & run | -| Go Syntax Highlighting | `go_syntax.py` | Syntax (`.go`) | `go run` | -| Java Syntax Highlighting | `java_syntax.py` | Syntax (`.java`) | `java` | -| Rust Syntax Highlighting | `rust_syntax.py` | Syntax (`.rs`) | rustc compile & run | -| French Translation | `french.py` | Language | - | +現成的插件(C、C++、Go、Java、Rust 語法高亮與執行設定,以及法文介面翻譯)放在 +[IDE_Plugins](https://github.com/Jeffrey-Plugin-Repos/IDE_Plugins)。 -All plugins are located in `exe/jeditor_plugins/`. +PyBreeze 多讀一個 JEditor 不讀的執行設定欄位:`"encoding"`,程式輸出用的編碼(`"cp950"`、`"utf-8"`, +或 `"locale"` 表示這台電腦自己的)。沒寫就用 IDE 的編碼讀,預設 UTF-8。Java 與中文化的編譯器輸出的是 +主控台的字碼頁,所以在非英文 Windows 上,Java 的執行設定要寫 `"encoding": "locale"`。 diff --git a/README.md b/README.md index 230ff4ef..6bee0d70 100644 --- a/README.md +++ b/README.md @@ -57,27 +57,27 @@ PyBreeze covers the full spectrum of automation testing out of the box: | Dimension | Module | What it does | |---|---|---| -| **API** | [APITestka](https://github.com/Intergration-Automation-Testing/APITestka) | RESTful testing with request builders, response analyzers, mock servers and assertions | -| **Web** | [WebRunner](https://github.com/Intergration-Automation-Testing/WebRunner) | Browser-driven interaction and testing with driver and locator integration | -| **GUI** | [AutoControl](https://github.com/Intergration-Automation-Testing/AutoControl) | Desktop automation via image recognition, coordinates, keyboard/mouse control and recording | -| **Load** | [LoadDensity](https://github.com/Intergration-Automation-Testing/LoadDensity) | High-concurrency performance testing for stability under pressure | +| **API** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful testing with request builders, response analyzers, mock servers and assertions | +| **Web** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | Browser-driven interaction and testing with driver and locator integration | +| **GUI** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | Desktop automation via image recognition, coordinates, keyboard/mouse control and recording | +| **Load** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | High-concurrency performance testing for stability under pressure | Plus: -- **File Automation** — file and directory operations via [automation-file](https://github.com/Intergration-Automation-Testing/AutomationFile) -- **Mail Automation** — report delivery via [MailThunder](https://github.com/Intergration-Automation-Testing/MailThunder) -- **Test Framework** — YAML-driven execution via [TestPioneer](https://github.com/Intergration-Automation-Testing/TestPioneer) +- **File Automation** — file and directory operations via [automation-file](https://github.com/Integration-Automation/FileAutomation) +- **Mail Automation** — report delivery via [MailThunder](https://github.com/Integration-Automation/MailThunder) +- **Test Framework** — YAML-driven execution via [TestPioneer](https://github.com/Integration-Automation/TestPioneer) Each module gets the same menu shape: **Run** (single script, batch directory, with or without an emailed report), **Help** (docs and GitHub open as in-IDE browser tabs), **Project** (scaffold a template directory), and where available a native GUI tab. ### IDE core -- **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml`, on top of JEditor's language support -- **Code editor** — built on [JEditor](https://github.com/Intergration-Automation-Testing/JEditor): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane -- **Script execution** — single or batch; large action files are passed by path, never through the command line, so Windows' ~32 KB argv limit is never a factor +- **Automation-aware syntax highlighting** — the `AT_*` / GUI / Web / Load keyword sets are registered for `.json`, and TestPioneer's schema for `.yml` and `.yaml`, on top of JEditor's language support +- **Code editor** — built on [JEditor](https://github.com/Integration-Automation/JEDITOR): tabs, project tree, format checker, debugger, terminal, variable inspector and a git client pane +- **Script execution** — single or batch, each run in a window of its own with a Stop button; action files are passed by path, and a script from the tab in front that is too long for a Windows command line (~32 KB) goes through a temporary file - **Report generation** — HTML / JSON / XML after a run, with optional email delivery - **Integrated JupyterLab** — launches as a tab, installing JupyterLab into the project venv if it is missing -- **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically +- **Virtual environment awareness** — `venv/` and `.venv/` are detected and used automatically; without one, scripts run on the interpreter the IDE runs on --- @@ -99,7 +99,7 @@ Targets: Python `requests`, a ready-to-run **pytest** test, **APITestka** (Pytho ![HAR import](images/tool_har_import.png) -Select what you want — or take everything listed — and generate one script using the same targets as the cURL importer. Repeated endpoints get numbered test names so no test silently replaces another; HTTP/2 pseudo-headers are dropped, and a `Cookie` header duplicating the recorded cookie list is removed so each value is sent once. A single selected request produces exactly what the cURL importer would. HAR is JSON, so this needs nothing beyond the standard library, and nothing is ever replayed for you. +Select what you want — or take everything listed — and generate one script using the same targets as the cURL importer. Repeated endpoints get numbered test names so no test silently replaces another; HTTP/2 pseudo-headers are dropped, and a `Cookie` header duplicating the recorded cookie list is removed so each value is sent once (cookies sharing a name, which a dictionary cannot hold, go as the header instead). An entry that cannot become a request, such as one with a malformed URL or method, is skipped and the rest load. A single selected request produces exactly what the cURL importer would. HAR is JSON, so this needs nothing beyond the standard library, and nothing is ever replayed for you. ### Response Inspector — paste a response, read everything in it @@ -126,13 +126,13 @@ Each is a tab or a dock, each has the same copy / open-in-editor / save-to-file ![JWT decoder, regex tester, HTTP status reference, JSON format](images/tools_montage_a.png) - **JWT Decoder** — header and payload as pretty JSON, with `exp` / `iat` / `nbf` / `auth_time` as readable UTC. Inspection only: the signature is never verified and the token is never trusted. -- **Regex Tester** — `IGNORECASE` / `MULTILINE` / `DOTALL` / `VERBOSE`, every match with offsets, numbered groups and named groups. An invalid pattern reports a friendly error instead of crashing. +- **Regex Tester** — `IGNORECASE` / `MULTILINE` / `DOTALL` / `VERBOSE`, every match with offsets, numbered groups and named groups. Enter in the pattern runs it. An invalid pattern reports a friendly error instead of crashing. - **HTTP Status Reference** — search the full status table (sourced from the standard library, so it stays current) by code prefix or keyword. - **JSON Format** — pretty-print or minify, with a clear validation error when the input is not JSON. ![Timestamp converter, hash generator, query/JSON, URL builder](images/tools_montage_b.png) -- **Timestamp Converter** — a Unix epoch (seconds or milliseconds, auto-detected) or an ISO-8601 date-time in, every representation out in UTC. Deterministic and independent of the local time zone. +- **Timestamp Converter** — a Unix epoch (seconds, milliseconds, microseconds or nanoseconds, auto-detected) or an ISO-8601 date-time (with `Z`, `+08`, `+0800` or `+08:00`, any number of fraction digits, basic or extended) in, every representation out in UTC. Deterministic and independent of the local time zone. - **Hash Generator** — SHA-256, SHA-512, SHA-1 and MD5 at once (MD5/SHA-1 for interoperability with `usedforsecurity=False`, never for security decisions). - **Query ⇄ JSON** — `application/x-www-form-urlencoded` to pretty JSON and back; repeated keys become arrays and vice versa. - **URL Parser / Builder** — scheme, host, port, path, query, fragment and credentials as an editable JSON object, and back again. Brackets IPv6 literals and re-encodes query parameters for you. @@ -149,12 +149,12 @@ A WYSIWYG `QGraphicsScene` editor: rectangle, rounded, ellipse and diamond nodes ![SSH client](images/ssh_client.png) -Password or private-key authentication, an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. +Password or private-key authentication (the key file picked with Browse, starting in `~/.ssh`), an interactive shell with ANSI handling and keepalive, and a lazy-loading SFTP tree with create-folder / rename / delete / upload / download. Every SFTP request runs in the background, so a stalled link never freezes the IDE. An upload asks before it replaces a file on the server, and a transfer can be cancelled from the tree's menu. Both directions write to a temporary file first, so a dropped link leaves the old copy whole. Unknown host keys are **not** auto-accepted: the SHA256 fingerprint is shown for confirmation on first connection (trust on first use) and persisted to `~/.pybreeze/ssh_known_hosts`. ### And also -- **File Tree Context Menu** — right-click to create, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager. Renaming or deleting a file open in an editor tab keeps the tab in sync. -- **Package Manager** — install automation modules and build tools from the menu, output in the shell pane. +- **File Tree Context Menu** — right-click to create, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager (a file shown selected in Explorer and Finder). Renaming or deleting a file open in an editor tab keeps the tab in sync. +- **Package Manager** — install automation modules and build tools from the menu, output in a run window. - **Integrated Documentation** — each module's docs and GitHub page open as in-IDE browser tabs. --- @@ -165,7 +165,7 @@ Password or private-key authentication, an interactive shell with ANSI handling ![AI code review client](images/ai_code_review.png) -*Shown in the pre-send state.* Send a selection to an LLM endpoint, then accept or reject the suggestion — the tally is kept in `~/.pybreeze/response_stats.txt`. The URL is SSRF-validated, redirects are not followed, and the response body is size-capped before it reaches the panel. +*Shown in the pre-send state.* Send a selection to an LLM endpoint, then accept or reject the suggestion — the tally is kept in `~/.pybreeze/response_stats.txt`. The URL is SSRF-validated, the connection goes only to the address that was checked, redirects are not followed, and the response body is size-capped before it reaches the panel. ### Chain-of-Thought Code Review (prthinker) @@ -173,13 +173,15 @@ Run the [prthinker](https://github.com/JE-Chen/Code-Review-Framework-Combining-L ![prthinker settings](images/prthinker_setting.png) -One settings form holds the inference backend (`remote`, `local`, OpenAI-compatible, Anthropic, Gemini, Cohere, Mistral, `claude-cli`, `codex-cli`), the code host (GitHub / GitLab / Gitea) and the repository. **Keys and tokens are handed to the review as environment variables, never on a command line** where a process list would show them — and they are masked in logs. +One settings form holds the inference backend (`remote`, `local`, OpenAI-compatible, Anthropic, Gemini, Cohere, Mistral, `claude-cli`, `codex-cli`), the code host (GitHub / GitLab / Gitea) and the repository. **Keys and tokens are handed to the review as environment variables, never on a command line** where a process list would show them — and they are masked in logs. The model name goes to whichever backend is chosen. Rule retrieval (RAG) is `off` unless set to `remote`, which asks the prthinker server's `/rag`: prthinker's local rule index ships with its repository, not with the package installed from it. The review runs with the interpreter chosen in `Python Env`, so PyBreeze itself can stay on an older Python than the 3.12 prthinker needs. ### CoT Prompt Editor ![CoT prompt editor](images/cot_prompt_editor.png) -Create and manage the multi-step review chain: first summary → first code review → linter → code smell detector → total summary. Each step's result feeds the final summary. Files are watched, so an external edit shows up immediately. +Create and manage the multi-step review chain: first summary → first code review → a judge of that review → linter → code smell detector → step-by-step analysis → total summary → a judge of the summary. Each step quotes the answers it needs from the steps before it. Files are watched, so an external edit shows up immediately. + +Run the chain from **Tools → AI → CoT Code Review** (a tab, or a dock from the Dock menu): paste the code, give the endpoint URL, and each step's answer appears in the selector as it arrives. ### Skill Prompt Editor & Skill Send @@ -200,7 +202,7 @@ PyBreeze inherits JEditor's plugin architecture, auto-discovered from a `jeditor - **Run configurations** — "Run with…" for interpreted (`go run main.go`) and compiled (`gcc main.c -o main` then run) languages, executed through PyBreeze's `FileRunnerProcess` with the compiled artifact cleaned up afterwards - **Plugin Browser** — browse and install plugins from remote repositories inside the IDE -Loaded plugins appear under their own **Plugins** menu with an About entry and one run action per supported suffix. See [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) for the full API and worked examples (C, C++, Go, Java, Rust, and a French translation). +Loaded plugins appear under their own **Plugins** menu with an About entry and one run action, labelled with the suffixes it runs. [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) covers what PyBreeze adds and links JEditor's guide, which has the full API and worked examples (C, C++, Go, Java, Rust, and a French translation). --- @@ -209,7 +211,7 @@ Loaded plugins appear under their own **Plugins** menu with an About entry and o - **English** (default) - **Traditional Chinese** (繁體中文) -Both dictionaries carry the same 571 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. +Both dictionaries carry the same 708 keys, and a test enforces that parity so a new string can never land in one language only. Further languages can be added via translation plugins. --- @@ -296,8 +298,8 @@ pip install pybreeze ### From source ```bash -git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git -cd AutomationEditor +git clone https://github.com/Integration-Automation/PyBreeze.git +cd PyBreeze pip install -r requirements.txt ``` @@ -305,7 +307,7 @@ pip install -r requirements.txt - **Python**: 3.10 – 3.14 - **OS**: Windows, macOS, Linux -- **GUI**: PySide6 6.11.0 (installed automatically) +- **GUI**: PySide6 6.11.2 (installed automatically) --- @@ -360,7 +362,7 @@ PyBreeze/ │ │ │ ├── python_task_process_manager.py # TaskProcessManager (core) │ │ │ ├── process_executor_utils.py # build_process / start_process │ │ │ ├── file_runner_process.py # Plugin run configs (any language) -│ │ │ ├── queue_pump.py # Shared QTimer drain +│ │ │ ├── queue_pump.py # Shared pipe reader + QTimer drain │ │ │ ├── api_testka/ auto_control/ web_runner/ │ │ │ ├── load_density/ file_automation/ mail_thunder/ │ │ │ ├── test_pioneer/ prthinker/ @@ -399,7 +401,7 @@ PyBreeze/ | Package | Purpose | |---|---| -| `PySide6` (6.11.0) | GUI framework (Qt for Python) | +| `PySide6` (6.11.2) | GUI framework (Qt for Python) | | `je-editor` | Base code editor engine | | `je_api_testka` | API testing automation | | `je_auto_control` | GUI/desktop automation | @@ -413,7 +415,7 @@ PyBreeze/ ### Development -`build`, `twine`, `sphinx`, `sphinx-rtd-theme`, `auto-py-to-exe`, `pytest`, `hypothesis` +`build`, `twine`, `sphinx`, `sphinx-rtd-theme`, `auto-py-to-exe`, `pytest`, `pytest-cov`, `hypothesis`, `ruff` --- @@ -424,7 +426,7 @@ python -m pip install -r dev_requirements.txt python -m pytest test/test_utils/ -v --tb=short ``` -- **Unit tests** — `test/test_utils/`, 60 modules covering the pure-logic layer (curl and HAR parsing, header analysis, SSRF validation, JWT, hashing, timestamps, diffing) plus headless Qt widget tests via `QT_QPA_PLATFORM=offscreen`, with Hypothesis property tests over the parsers +- **Unit tests** — `test/test_utils/`, covering the pure-logic layer (curl and HAR parsing, header analysis, SSRF validation, JWT, hashing, timestamps, diffing) plus headless Qt widget tests via `QT_QPA_PLATFORM=offscreen`, with Hypothesis property tests over the parsers - **Startup tests** — `test/unit_test/start_automation/` launches the IDE in debug mode and verifies it comes up and exits cleanly - **CI** — GitHub Actions on Windows across Python 3.10 – 3.14, on every push and PR plus a nightly run - **Static analysis** — SonarCloud, Codacy and Bandit diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index c7069321..b45e056a 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -40,23 +40,23 @@ PyBreeze 开箱即用,涵盖自动化测试的完整范围: | 维度 | 模块 | 说明 | |---|---|---| -| **Web 自动化** | [WebRunner](https://github.com/Intergration-Automation-Testing/WebRunner) | 浏览器交互模拟与测试,深度集成浏览器驱动与元素定位器 | -| **API 自动化** | [APITestka](https://github.com/Intergration-Automation-Testing/APITestka) | RESTful API 开发与测试,内置请求构建器、响应分析器、Mock 服务器及断言验证 | -| **GUI 自动化** | [AutoControl](https://github.com/Intergration-Automation-Testing/AutoControl) | 桌面应用程序自动化,支持图像识别、坐标定位、键盘鼠标控制及动作录制 | -| **负载与压力测试** | [LoadDensity](https://github.com/Intergration-Automation-Testing/LoadDensity) | 高并发性能测试引擎,用于监控系统在极端压力下的稳定性 | +| **Web 自动化** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 浏览器交互模拟与测试,深度集成浏览器驱动与元素定位器 | +| **API 自动化** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful API 开发与测试,内置请求构建器、响应分析器、Mock 服务器及断言验证 | +| **GUI 自动化** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面应用程序自动化,支持图像识别、坐标定位、键盘鼠标控制及动作录制 | +| **负载与压力测试** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | 高并发性能测试引擎,用于监控系统在极端压力下的稳定性 | 此外还包含: -- **文件自动化** — 通过 [automation-file](https://github.com/Intergration-Automation-Testing/AutomationFile) 模块实现自动化文件与目录操作 -- **邮件自动化** — 通过 [MailThunder](https://github.com/Intergration-Automation-Testing/MailThunder) 实现自动化邮件发送(例如测试报告投递) -- **测试框架** — 通过 [TestPioneer](https://github.com/Intergration-Automation-Testing/TestPioneer) 实现结构化 YAML 驱动的测试执行 +- **文件自动化** — 通过 [automation-file](https://github.com/Integration-Automation/FileAutomation) 模块实现自动化文件与目录操作 +- **邮件自动化** — 通过 [MailThunder](https://github.com/Integration-Automation/MailThunder) 实现自动化邮件发送(例如测试报告投递) +- **测试框架** — 通过 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 实现结构化 YAML 驱动的测试执行 ### IDE 核心功能 PyBreeze 不仅仅是一个代码编辑器——它是自动化生命周期的指挥中心: - **语法高亮** — 内置 Python 语法高亮,针对自动化库(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度关键字识别。可通过插件添加自定义语法规则。 -- **代码编辑器** — 基于 [JEditor](https://github.com/Intergration-Automation-Testing/JEditor) 构建,提供完整的编辑器功能,包含标签页管理、文件树导航与项目工作区支持。 +- **代码编辑器** — 基于 [JEditor](https://github.com/Integration-Automation/JEDITOR) 构建,提供完整的编辑器功能,包含标签页管理、文件树导航与项目工作区支持。 - **脚本执行** — 直接在 IDE 中执行自动化脚本,并实时显示输出。支持单脚本与多脚本批量执行。 - **报告生成** — 自动化模块可在测试执行后生成 HTML、JSON 和 XML 报告,并支持可选的电子邮件投递。 - **集成 JupyterLab** — 在 PyBreeze 中直接以标签页方式启动 JupyterLab,进行交互式笔记本开发。若未安装 JupyterLab 将自动安装。 @@ -227,8 +227,8 @@ pip install pybreeze ### 从源码安装 ```bash -git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git -cd AutomationEditor +git clone https://github.com/Integration-Automation/PyBreeze.git +cd PyBreeze pip install -r requirements.txt ``` @@ -236,7 +236,7 @@ pip install -r requirements.txt - **Python**:3.10 或更高版本 - **操作系统**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.0(自动安装) +- **GUI 框架**:PySide6 6.11.2(自动安装) --- @@ -382,7 +382,7 @@ PyBreeze/ | 包 | 用途 | |---|---| -| `PySide6` (6.11.0) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.2) | GUI 框架(Qt for Python)| | `je-editor` | 基础代码编辑器引擎 | | `je_api_testka` | API 测试自动化 | | `je_auto_control` | GUI/桌面自动化 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index bd87015a..08a57468 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -40,23 +40,23 @@ PyBreeze 開箱即用,涵蓋自動化測試的完整範疇: | 維度 | 模組 | 說明 | |---|---|---| -| **Web 自動化** | [WebRunner](https://github.com/Intergration-Automation-Testing/WebRunner) | 瀏覽器互動模擬與測試,深度整合瀏覽器驅動與元素定位器 | -| **API 自動化** | [APITestka](https://github.com/Intergration-Automation-Testing/APITestka) | RESTful API 開發與測試,內建請求建構器、回應分析器、Mock 伺服器及斷言驗證 | -| **GUI 自動化** | [AutoControl](https://github.com/Intergration-Automation-Testing/AutoControl) | 桌面應用程式自動化,支援圖像辨識、座標定位、鍵盤滑鼠控制及動作錄製 | -| **負載與壓力測試** | [LoadDensity](https://github.com/Intergration-Automation-Testing/LoadDensity) | 高併發效能測試引擎,用於監控系統在極端壓力下的穩定性 | +| **Web 自動化** | [WebRunner](https://github.com/Integration-Automation/WebRunner) | 瀏覽器互動模擬與測試,深度整合瀏覽器驅動與元素定位器 | +| **API 自動化** | [APITestka](https://github.com/Integration-Automation/APITestka) | RESTful API 開發與測試,內建請求建構器、回應分析器、Mock 伺服器及斷言驗證 | +| **GUI 自動化** | [AutoControl](https://github.com/Integration-Automation/AutoControlGUI) | 桌面應用程式自動化,支援圖像辨識、座標定位、鍵盤滑鼠控制及動作錄製 | +| **負載與壓力測試** | [LoadDensity](https://github.com/Integration-Automation/LoadDensity) | 高併發效能測試引擎,用於監控系統在極端壓力下的穩定性 | 此外還包含: -- **檔案自動化** — 透過 [automation-file](https://github.com/Intergration-Automation-Testing/AutomationFile) 模組實現自動化檔案與目錄操作 -- **郵件自動化** — 透過 [MailThunder](https://github.com/Intergration-Automation-Testing/MailThunder) 實現自動化郵件寄送(例如測試報告傳遞) -- **測試框架** — 透過 [TestPioneer](https://github.com/Intergration-Automation-Testing/TestPioneer) 實現結構化 YAML 驅動的測試執行 +- **檔案自動化** — 透過 [automation-file](https://github.com/Integration-Automation/FileAutomation) 模組實現自動化檔案與目錄操作 +- **郵件自動化** — 透過 [MailThunder](https://github.com/Integration-Automation/MailThunder) 實現自動化郵件寄送(例如測試報告傳遞) +- **測試框架** — 透過 [TestPioneer](https://github.com/Integration-Automation/TestPioneer) 實現結構化 YAML 驅動的測試執行 ### IDE 核心功能 PyBreeze 不僅僅是一個程式碼編輯器——它是自動化生命週期的指揮中心: - **語法高亮** — 內建 Python 語法高亮,針對自動化函式庫(APITestka、AutoControl、WebRunner、LoadDensity 等)提供深度關鍵字識別。可透過插件新增自訂語法規則。 -- **程式碼編輯器** — 基於 [JEditor](https://github.com/Intergration-Automation-Testing/JEditor) 構建,提供完整的編輯器功能,包含分頁管理、檔案樹瀏覽與專案工作區支援。 +- **程式碼編輯器** — 基於 [JEditor](https://github.com/Integration-Automation/JEDITOR) 構建,提供完整的編輯器功能,包含分頁管理、檔案樹瀏覽與專案工作區支援。 - **腳本執行** — 直接在 IDE 中執行自動化腳本,並即時顯示輸出。支援單一腳本與多腳本批次執行。 - **報告生成** — 自動化模組可在測試執行後生成 HTML、JSON 和 XML 報告,並支援可選的電子郵件傳遞。 - **整合 JupyterLab** — 在 PyBreeze 中直接以分頁方式啟動 JupyterLab,進行互動式筆記本開發。若未安裝 JupyterLab 將自動安裝。 @@ -227,8 +227,8 @@ pip install pybreeze ### 從原始碼安裝 ```bash -git clone https://github.com/Intergration-Automation-Testing/AutomationEditor.git -cd AutomationEditor +git clone https://github.com/Integration-Automation/PyBreeze.git +cd PyBreeze pip install -r requirements.txt ``` @@ -236,7 +236,7 @@ pip install -r requirements.txt - **Python**:3.10 或更高版本 - **作業系統**:Windows、macOS、Linux -- **GUI 框架**:PySide6 6.11.0(自動安裝) +- **GUI 框架**:PySide6 6.11.2(自動安裝) --- @@ -382,7 +382,7 @@ PyBreeze/ | 套件 | 用途 | |---|---| -| `PySide6` (6.11.0) | GUI 框架(Qt for Python)| +| `PySide6` (6.11.2) | GUI 框架(Qt for Python)| | `je-editor` | 基礎程式碼編輯器引擎 | | `je_api_testka` | API 測試自動化 | | `je_auto_control` | GUI/桌面自動化 | diff --git a/architecture.md b/architecture.md new file mode 100644 index 00000000..007211b4 --- /dev/null +++ b/architecture.md @@ -0,0 +1,239 @@ +# PyBreeze Architecture + +> Short overview for people and agents. Per-module detail lives in [`architecture_explore.md`](architecture_explore.md). +> Last verified: 2026-09-23 against `dcd35c6` on `dev`. + +## 1. Purpose + +PyBreeze is an automation-first Python IDE built on JEditor. CI publishes it as `pybreeze` +(`pyproject.toml`) from `main`; `dev.toml` describes a `pybreeze_dev` package that nothing +publishes any more (workspace X-13). It does not have its own editor. +Instead it subclasses JEditor's main window and adds menus, tool tabs and docks around it: + +- menus that drive the automation packages (AutoControl, WebRunner, APITestka, LoadDensity, + FileAutomation, MailThunder, TestPioneer); +- prthinker and chain-of-thought LLM code review; +- SSH/SFTP, embedded JupyterLab and a diagram editor; +- a set of HTTP developer tools. + +The central rule: the editor process never runs user scripts itself. They run in subprocesses, and +their output reaches the UI through Queue + QTimer. + +## 2. Layers and directories + +| Path | Responsibility | +| --- | --- | +| `pybreeze/__init__.py` | Facade: `start_editor`, `PyBreezeMainWindow`, `EDITOR_EXTEND_TAB`, re-exported JEditor plugin functions | +| `pybreeze/__main__.py`, `exe/start_pybreeze.py` | Launch scripts (module run, executable build) | +| `pybreeze/pybreeze_ui/editor_main/` | `PyBreezeMainWindow(EditorMain)`, `start_editor()`, file-tree context menu | +| `pybreeze/pybreeze_ui/menu/` | Menu builders; `build_menubar.py` is the single entry. Holds `automation_menu/` (factory, per-package menus, TestPioneer, prthinker), `install_menu/`, `tools/tools_menu.py`, `plugin_menu/`, `extend_jeditor_tab_menu/` | +| `pybreeze/pybreeze_ui/tools_gui/` | Thin tool tabs (cURL/HAR import, JWT, regex, diff, headers, …) backed by `pybreeze/utils/` | +| `pybreeze/pybreeze_ui/diagram_editor/` | Diagram editor (QGraphicsScene, Mermaid import, PNG/SVG export) | +| `pybreeze/pybreeze_ui/extend_ai_gui/`, `dialog/` | LLM code-review chain and prompt editors; prthinker settings dialog | +| `pybreeze/pybreeze_ui/connect_gui/` | `ssh/` terminal + SFTP tree; `url/` HTTP code-review client | +| `pybreeze/pybreeze_ui/jupyter_lab_gui/`, `show_code_window/`, `syntax/` | JupyterLab tab; `CodeWindow` subprocess output window; automation keyword highlighting | +| `pybreeze/pybreeze_ui/thread_keeper.py` | `let_run_out()`: a worker `QThread` whose widget closed is kept until it ends instead of being waited for | +| `pybreeze/pybreeze_ui/gui_thread_gc.py` | `GuiThreadGarbageCollector`: automatic garbage collection off, collected on a GUI-thread timer instead (installed by `start_editor()`) | +| `pybreeze/extend/process_executor/` | Subprocess isolation layer: `TaskProcessManager`, `process_executor_utils.py`, `FileRunnerProcess`, `queue_pump.py`, one sub-package per automation package, plus `test_pioneer/` and `prthinker/` | +| `pybreeze/extend/mail_thunder_extend/`, `prthinker_extend/` | Post-test email hook; prthinker settings and argument assembly (pure logic) | +| `pybreeze/extend_multi_language/` | PyBreeze's English and Traditional Chinese strings, merged into JEditor's dictionaries | +| `pybreeze/utils/` | Pure logic, no Qt or JEditor (`test_utils_has_no_qt.py` guards it): request parsing and codegen, HTTP tools, `network/` SSRF validation, pinned connections and capped reads, exceptions, logging, `app_dirs.py`, `subprocess_util.py`, `terminal_text.py` (terminal escapes stripped for the SSH terminal and the run window) | +| `test/test_utils/` | Unit tests (pure logic and headless widgets). `test/unit_test/start_automation/` holds the launch tests | +| `pyproject.toml`, `dev.toml` | Stable packaging (CI bumps and publishes it) and the unpublished dev packaging (keep its dependencies identical) | +| `.github/workflows/` | `dev.yml`, `stable.yml` (unit tests on a Windows matrix, then SonarCloud) | +| `docs/`, `linux_package_source/`, `architecture_diagram/` | Sphinx docs, Debian package source, architecture image | + +The layers are presentation (`pybreeze_ui/`), then execution (`extend/`), then foundation (`utils/`, +`extend_multi_language/`), then external subprocesses. + +## 3. Entry points and public interfaces + +- **CLI**: `python -m pybreeze` (`pybreeze/__main__.py`). No console script is declared. It and + `exe/start_pybreeze.py` start the IDE only under `if __name__ == "__main__":` with + `multiprocessing.freeze_support()`: in the packaged executable the regex tester runs patterns in + a spawned process, which re-runs the executable. From source it runs them in a plain worker script + (`python -I -S -c`), so a launch script without the guard is safe. +- **Programmatic**: `pybreeze.start_editor(debug_mode=False, theme="dark_amber.xml", **kwargs)`. + `debug_mode=True` adds an auto-close timer, which CI uses. +- **Main window**: `PyBreezeMainWindow` exposes `tab_widget`, `current_run_code_window` and + `python_compiler`. +- **Custom tabs**: `EDITOR_EXTEND_TAB: dict[str, type[QWidget]]` in + `pybreeze/pybreeze_ui/editor_main/main_ui.py`. This is PyBreeze's own registry, separate from + JEditor's dict of the same name. +- **Plugin API (re-exported)**: `load_external_plugins`, `register_programming_language`, + `register_natural_language`. +- **Persisted state**: `~/.pybreeze/` via `utils/app_dirs.pybreeze_data_dir()` (SSH known hosts, + prthinker settings, edited prompts, review history, and `logs/PyBreeze.log`, which + `$PYBREEZE_LOG_FILE` can move). The editor settings inherited from JEditor + stay in `.jeditor/` under the working directory. + +## 4. Main flows + +**Startup** + +``` +python -m pybreeze → start_editor() → QApplication → PyBreezeMainWindow() + → update_language_dict() [before JEditor picks the startup language] + → EditorMain.__init__(extend=True) [JEditor builds the editor, loads jeditor_plugins/] + → drop JEditor Help menu → add_menu_to_menubar() + → syntax_extend_package() → EDITOR_EXTEND_TAB tabs → setup_file_tree_context_menu() + → apply_stylesheet(theme) → showMaximized() → startup_setting() → exec() → os._exit() +``` + +Before PySide6 is imported, `main_ui.py` sets `LOCUST_SKIP_MONKEY_PATCH=1` to keep LoadDensity's +gevent patching away from Qt. + +**Run an automation script** + +``` +Automation menu (automation_menu_factory.build_automation_menu) → call_() in +extend/process_executor// → build_process() (process_executor_utils.py) + → CodeWindow + TaskProcessManager → python -m --execute_str | --execute_file + → stdout/stderr reader threads → Queue → QTimer → pump_message_queue() → CodeWindow.append_output() + → optional report_mail_hook() → send_after_test() (mail_thunder_extend; mails this run's report on a + thread of its own, never an earlier run's, and the run window says whether it went) +``` + +**Run a non-Python file through a plugin** + +``` +Run with… / Plugins menu (menu/plugin_menu/) → get_all_plugin_run_configs() + → run_current_file_with() → save_current_file_for_run() [tab's own encoding and line ending] + → FileRunnerProcess.run_file() → interpret, or compile then run → CodeWindow +``` + +## 5. Extension points + +- **Custom tabs**: add entries to `EDITOR_EXTEND_TAB` (`pybreeze_ui/editor_main/main_ui.py`) before + `start_editor()`. +- **File plugins**: `jeditor_plugins/` in the working directory, loaded by JEditor + (`je_editor/plugins/plugin_loader.py`). `PLUGIN_RUN_CONFIG` entries appear in the Run with… and + Plugins menus and execute via `FileRunnerProcess`. The plugin browser tab reuses JEditor's + `PluginBrowserWidget`. See `PLUGIN_GUIDE.md`. +- **New automation package**: + - add a sub-package under `extend/process_executor/` with a `_PACKAGE` constant that calls + `build_process()`; + - add a menu: one `AutomationMenu(...)` passed to `build_automation_menu()` + (`pybreeze_ui/menu/automation_menu/automation_menu_factory.py`), wired in + `menu/build_menubar.py`; + - add an installer in `menu/install_menu/automation_menu/`; + - add keywords in `pybreeze_ui/syntax/syntax_keyword.py`. +- **New tool tab or dock**: a widget in `pybreeze_ui/tools_gui/`, its logic in `pybreeze/utils/`, and + rows in `_WIDGET_FACTORIES` / `_TAB_ACTIONS` / `_DOCK_ACTIONS` / `_DOCK_TITLES` + (`pybreeze_ui/menu/tools/tools_menu.py`). +- **UI strings**: add keys to both `extend_multi_language/extend_english.py` and + `extend_traditional_chinese.py`. `test/test_utils/test_language_parity.py` enforces parity. + +## 6. Cross-project boundaries + +- **JEditor (upstream)**: `PyBreezeMainWindow` subclasses `je_editor.EditorMain` in extend mode + and calls `super().__init__(debug_mode, show_system_tray_ray, extend=True)`. `pybreeze/__init__.py` + re-exports JEditor's plugin API. Everything else PyBreeze takes from the top level is in + je_editor's `__all__`, except for these names, which it imports from module paths JEditor has not + promised to keep. `test/test_utils/test_jeditor_contract.py` pins each path and the shape + PyBreeze calls it with, and fails if the code imports an internal name that is not on this list: + + | Name | JEditor module | Used in | + | --- | --- | --- | + | `PluginBrowserWidget` | `pyside_ui.main_ui.plugin_browser.plugin_browser_widget` | `menu/plugin_menu/build_plugin_menu.py` | + | `DestroyDock` | `pyside_ui.main_ui.dock.destroy_dock` | `closing.py` (`AskingDock`, which the Dock menu builds), `editor_main/main_ui.py` | + | `init_new_auto_save_thread`, `auto_save_manager_dict`, `file_is_open_manager_dict` | `pyside_ui.code.auto_save.auto_save_manager` | `editor_main/file_tree_context_menu.py` (a rename restarts the tab's auto-save on the new path) | + | `FullEditorWidget` | `pyside_ui.main_ui.editor.editor_widget_dock` | `editor_main/file_tree_context_menu.py` (a rename re-points a docked editor's `current_file`) | + | `check_and_choose_venv` | `utils.venv_check.check_venv` | `extend/process_executor/python_task_process_manager.py` | + | `choose_file_get_save_file_path` | `pyside_ui.dialog.file_dialog.save_file_dialog` | `menu/plugin_menu/build_run_with_menu.py` | + | `write_file_with_encoding` | `utils.file.save.save_file` | `menu/plugin_menu/build_run_with_menu.py` | + | `DEFAULT_ENCODING`, `LINE_ENDING_LF` | `utils.encodings.text_codec` | `menu/plugin_menu/build_run_with_menu.py` | + | `actually_color_dict` | `pyside_ui.main_ui.save_settings.user_color_setting_file` | `show_code_window/code_window.py`, `automation_menu/auto_control_menu/build_autocontrol_menu.py` | + + PyBreeze also relies on `EditorWidget`'s `current_file`, `code_edit`, `file_encoding`, + `line_ending`, `mark_ignore_next_file_change()` and `mark_saved()`, on its private `_file_watcher`, + `_ignore_next_change`, `_is_modified` and `_on_text_changed()` (a rename puts the unsaved mark + back after `rename_self_tab()` clears it), on `EditorMain.close_tab(index)` (overridden to ask a + tab's `may_close()` first, and to delete a closed tool tab, which its `removeTab` keeps) and on `CodeEditor`'s `reset_highlighter()`, `load_git_baseline()` and + `start_language_server()` (a rename moves the tab the way `open_an_file` does), and on `language_wrapper`'s + `choose_language_dict` serving English and Traditional Chinese from the exported dict objects + themselves. Having je_editor export the names in the table is workspace X-17. It + merges its strings by mutating JEditor's `english_word_dict` and `traditional_chinese_word_dict` + in place, and writes its `application_name` into every dict in + `language_wrapper.choose_language_dict` (`extend_multi_language/update_language_dict.py`). That has + to happen before `EditorMain.__init__`: JEditor serves every language but English from a merged + copy built when it picks the startup language, so strings added later are missing and a `None` + menu title crashes Qt (`test_startup_language.py`). JEditor translation changes must keep + `test_language_parity.py` green. +- **Automation packages**: `je_auto_control`, `je_web_runner`, `je_api_testka`, `je_load_density`, + `automation_file` and `je_mail_thunder` run as `python -m --execute_str/--execute_file` + (`extend/process_executor/python_task_process_manager.py`). TestPioneer runs as + `python -m test_pioneer -e ` through the same manager's `start_module_process` + (`extend/process_executor/test_pioneer/`). +- **prthinker**: runs as `python -m prthinker review-file ` or + `review-pr --pr-number ` via `TaskProcessManager.start_module_process` + (`extend/process_executor/prthinker/`), with the interpreter chosen in the IDE, which needs Python + 3.12+ (PyBreeze's own may be older). Settings travel as environment variables, never argv + (`prthinker_setting.environment_for`): `PRTHINKER_BACKEND`, the chosen backend's model variable + (`MODEL_ENVIRONMENT`: `PRTHINKER_MODEL_NAME` for `remote`/`local`, otherwise + `PRTHINKER__MODEL`), `PRTHINKER_REMOTE_URL`, `PRTHINKER_REMOTE_API_KEY`, + `PRTHINKER_OPENAI_API_KEY`, `PRTHINKER_OPENAI_BASE_URL`, `PRTHINKER_ANTHROPIC_API_KEY`, + `PRTHINKER_PLATFORM`, `PRTHINKER_PLATFORM_BASE_URL`, `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, and + always both `PRTHINKER_RAG_ENABLED` and `PRTHINKER_REMOTE_RAG` (the child inherits the IDE's + environment, so one left out would be decided by whatever is exported there). `BACKENDS` must stay a + subset of `prthinker.config.BackendKind`. It is not on PyPI: the Install menu asks for a local + source folder and installs `[runner]`; that package excludes prthinker's `codes/` (the local + RAG index), which is why local retrieval is never left on. `test_prthinker_contract.py` runs the + real prthinker (CI's 3.12 leg) against the arguments and variables PyBreeze builds, and builds + prthinker's config (`prthinker.cli._build_parser`, `_build_config`, both in its `__all__`) to check + each backend gets the model. +- **IDE_Plugins**: the plugin browser's default repo (set in JEditor). Its run configs execute here via + `FileRunnerProcess`. PyBreeze reads one key JEditor's plugin guide does not define: `"encoding"`, the + encoding the program's output is in (`"locale"` for the machine's own), documented in `PLUGIN_GUIDE.md`. +- **PySide6 pin**: it must match JEditor and FrontEngine. PyBreeze pins it in `pyproject.toml`, + `dev.toml` and `requirements.txt`. All three pin 6.11.2, the exact pin of the published je_editor + 1.0.26 and frontengine 1.0.78 (both `==`, so PyBreeze cannot move ahead of them without becoming + uninstallable). PyBreeze moves when the published je_editor does (workspace X-1). + +## 7. Design constraints + +- Keep `architecture_explore.md` and the CLAUDE.md tree current in the same change + (CLAUDE.md § Architecture). +- Never update UI from a worker thread: use Queue + QTimer or Signal/Slot. Keep every menu `QAction` + alive: store it on the main window or parent it to its menu. Custom exceptions derive from `ITEException`. Log via `pybreeze_logger` + (§ Conventions). +- An executor is held by the run window it writes to (`CodeWindow.runner`): its QTimer connection does + not keep it alive, and a collected executor stops pumping output before the exit line. Closing the + IDE stops every run still going (`CodeWindow.stop_runner()`): a child is a separate, console-less + process that would otherwise outlive it unseen. +- Every outbound request to a user URL passes SSRF validation (`utils/network/url_validation.py`) + with timeouts and size caps, and connects through `utils/network/public_http.py`, which connects + only to the addresses it checks as it connects, trying each in turn (§ Security › Network). +- SSH uses the interactive host-key policy, never auto-add (§ Security › SSH). +- Work that waits on a network — an AI review request, a diagram's image downloads, an SSH + connect, an SFTP listing or transfer — runs on a + `QThread` and reaches the UI only through signals. A request panel that closes mid-request hands + its thread to `thread_keeper.let_run_out()` (cut off from the panel, kept until it ends) rather + than waiting for it on the UI thread; a running `QThread` must never be destroyed. +- Cyclic garbage is collected on the GUI thread only (`gui_thread_gc.py`, installed by + `start_editor()`): an automatic collection runs in whichever thread allocates, and one on a worker + destroyed Qt objects there and crashed the IDE. Never call `gc.enable()` in the IDE. +- Subprocesses use argument lists, `shell=False` and a `timeout`, and pass secrets through `env` + (§ Security › Subprocess). +- The JupyterLab server stays localhost-only (§ Security › JupyterLab). +- Persist data only under `~/.pybreeze/` (§ Security › File I/O). +- Complexity, length and nesting caps; no silent `except`; no `assert` in runtime code + (§ Code quality gates). +- `main` is stable and `dev` is development. CI owns the version: never edit it by hand. + SonarCloud automatic analysis stays off (§ Branching & CI). +- Commit and PR text must follow the attribution rules (§ Commit & PR rules). + +## 8. When to update this file + +Update it in the same change when any of these changes: + +- a top-level package or directory in §2; +- an entry point or an export in `pybreeze/__init__.py`; +- the startup, execution or plugin-run flow in §4; +- an extension point in §5; +- a cross-repo contract in §6 (JEditor base class or imports, the subprocess command-line protocol, + the prthinker install path, the PySide6 pin); +- a CLAUDE.md section that §7 points to is renamed. + +Per-module changes go to `architecture_explore.md`. Refresh the "Last verified" line when you +re-check this file. diff --git a/architecture_explore.md b/architecture_explore.md index 313f66e9..858bd70d 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -1,6 +1,6 @@ # PyBreeze 架構探勘 / Architecture Exploration -> 掃描範圍:`pybreeze/`(149 個 `.py`、約 16,400 行)+ `test/`、`exe/`、`docs/`、CI 設定 +> 掃描範圍:`pybreeze/`(201 個 `.py`、約 22,200 行,不含空行與註解約 17,300 行)+ `test/`、`exe/`、`docs/`、CI 設定 > 對應版本:`pyproject.toml` 1.0.21(stable)/`dev.toml` 1.0.14(dev),分支 `dev` --- @@ -30,7 +30,7 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE │ 表現層 Presentation pybreeze/pybreeze_ui/ │ │ editor_main 主視窗(繼承 EditorMain)+ 檔案樹右鍵選單 │ │ menu 選單建構器:automation / install / tools / plugin / dock │ - │ tools_gui 19 個工具分頁(curl、HAR、JWT、diff、regex…) │ + │ tools_gui 13 個工具 widget(curl、HAR、JWT、diff、regex…) │ │ diagram_editor 架構圖 WYSIWYG 編輯器(QGraphicsScene) │ │ extend_ai_gui CoT 程式碼審查、Prompt 編輯器、Skills 發送 │ │ connect_gui SSH 終端 + SFTP 檔案樹、AI Code Review HTTP client │ @@ -43,14 +43,14 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ┌─────────────────────────────────────────────────────────────────────────┐ │ 執行層 Execution pybreeze/extend/ │ │ process_executor/ 子行程隔離層(Strategy + Template Method) │ - │ mail_thunder_extend/ 測試後寄報告 hook │ + │ mail_thunder_extend/ 測試後寄報告 hook(mail_thunder_setting.py) │ │ prthinker_extend/ prthinker 設定與指令組裝(純邏輯) │ └────────────────────────────────┬────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ 基礎層 Foundation │ - │ pybreeze/utils/ 14 個工具子套件(純邏輯,可單測) │ - │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 571 鍵) │ + │ pybreeze/utils/ 18 個工具子套件(純邏輯,可單測) │ + │ pybreeze/extend_multi_language/ 內建 i18n(英 / 繁中,各 708 鍵) │ └─────────────────────────────────────────────────────────────────────────┘ ▼ 外部子行程:python -m je_api_testka / je_auto_control / je_web_runner / @@ -62,18 +62,19 @@ PyBreeze 是一個「自動化優先」的 Python IDE,建構在 **PySide6 + JE ## 3. 啟動流程 -`pybreeze/pybreeze_ui/editor_main/main_ui.py:98` 的 `start_editor()`: +`pybreeze/pybreeze_ui/editor_main/main_ui.py:189` 的 `start_editor()`: -1. 取得(或建立)`QApplication` +1. 取得(或建立)`QApplication`,裝上 `collect_garbage_on_gui_thread()`(`pybreeze_ui/gui_thread_gc.py`:關掉自動垃圾回收,改在 UI 執行緒上定時回收,見 §16) 2. 建立 `PyBreezeMainWindow`,其 `__init__` 依序: + - `update_language_dict()` 併入 PyBreeze 的 708 條翻譯——**必須在 `super().__init__` 之前**:JEditor 在那裡依設定挑啟動語言,英文以外的語言讀的是當下合併出來的一份副本,之後才加進去的字串它看不到,選單拿到 `None` 標題就讓 Qt 當掉(access violation) - `super().__init__(..., extend=True)` — JEditor 在此已呼叫 `load_external_plugins()`,自動掃描 CWD 下的 `jeditor_plugins/` - 刪掉 JEditor 原本的 Help 選單 - - `update_language_dict()` 併入 PyBreeze 的 571 條翻譯 - 設定標題、Windows AppUserModelID、圖示 - `add_menu_to_menubar()` — 建構全部選單(見 §5) - - `syntax_extend_package()` — 註冊 `.json` / `.yml` 自動化關鍵字高亮 - - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁 - - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單 + - `syntax_extend_package()` — 註冊 `.json` / `.yml` / `.yaml` 自動化關鍵字高亮 + - 依 `EDITOR_EXTEND_TAB` 註冊表加入外部擴充分頁(`_add_extend_tabs()`:每一個分頁各自建,建不起來的只記 log,不會讓整個 IDE 起不來) + - `setup_file_tree_context_menu()` — 掛上檔案樹右鍵選單。改名時開著的分頁跟著檔案走(改資料夾也一樣,底下每個開著的檔案都跟著走):先停掉分頁的自動存檔、改名、再用新路徑重開一條(`_stop_auto_save()` / `_start_auto_save()`)——JEditor 的存檔執行緒只認開檔當下的路徑,沒辦法改指向。外部修改監視也跟著搬(改名前就先移除,檔案搬走後 Windows 放不掉舊名),並照 `open_an_file` 重載語法高亮、git 基準與語言伺服器(`rename_self_tab()` 會清掉「未儲存」標記,有未存的編輯就用 `_on_text_changed()` 放回去,否則改名後五秒內關分頁會直接丟掉編輯);Dock Editor(`FullEditorWidget`,關閉時才寫回、檔案不存在就不寫)的 `current_file` 也改指新路徑(`_dock_editors_under()`)。新增與改名的名稱不能帶磁碟代號、根目錄、`..` 或 `:`,也不能解析到資料夾外(`_inside()`;改名只能是單一名稱)。刪除資料夾用 `remove_folder()`(唯讀檔清掉唯讀屬性再刪,git 的物件檔就是唯讀),符號連結與 junction 只刪連結本身。刪除時同樣用 `_editors_under()`:檔案或資料夾底下每個開著的分頁先停掉自動存檔,再刪;刪完只關掉檔案真的不見了的分頁,刪不掉(被鎖住、唯讀)的檔案分頁留著、自動存檔重開。「在檔案總管中顯示」由 `reveal_command()` 組指令:Windows 用 Explorer 的 `/select,`、macOS 用 `open -R` 把檔案選起來,其他平台 `xdg-open` 只能開資料夾;啟動失敗(例如沒有 `xdg-open`)經 `_perform_file_op()` 跳警告 + - `close_tab()` 覆寫 JEditor 的:分頁有 `may_close()` 就先問(提示詞編輯器、架構圖編輯器有未存的變更時會問);關掉的工具分頁 `deleteLater()`(JEditor 的 `removeTab` 不刪 widget,關過的工具分頁會留到 IDE 結束),JEditor 自己的編輯器分頁不動,關閉 IDE 時也先問過每個分頁與 dock,有一個說不就取消關閉 - `debug_mode=True` 時啟動 10 秒自動關閉 `QTimer`(CI 用) 3. `apply_stylesheet()` 套 qt_material 主題(預設 `dark_amber.xml`) 4. `showMaximized()` → `startup_setting()` → `app.exec()` @@ -91,15 +92,16 @@ Template Method 定義的子行程生命週期: | 階段 | 做的事 | |---|---| -| 解譯器解析 | `renew_path()` → 用 `find_venv_path()` 找 `venv/`、`.venv/`,交給 `check_and_choose_venv()`;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | -| 啟動 | `subprocess.Popen(args, shell=False, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)` | -| 讀取 | 兩條 daemon Thread 各自 `readline()` stdout / stderr,塞進 `Queue`;**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | -| 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則 | -| 收尾 | `exit_program()`:join 執行緒(timeout 2s)→ drain queue → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | +| 解譯器解析 | `renew_path()` → 執行視窗帶著 IDE 選定的直譯器(`python_compiler`,由 `build_task_process()` 從主視窗抄過來)就用它;沒選才用 `default_interpreter()`:工作目錄有 `venv/`、`.venv/` 就交給 `check_and_choose_venv()`,沒有就用 IDE 自己的直譯器(`sys.executable`,和 JupyterLab 分頁一樣;PATH 上的 `python3` 在 Windows 常是 Store 的空殼,exit 9009 什麼也不說),只有打包版才去 PATH 找;找不到時**不拋例外**,直接把錯誤寫進執行視窗並回傳 `False` | +| 啟動 | `subprocess.Popen(args, shell=False, stdin=DEVNULL, creationflags=CREATE_NO_WINDOW, env=PYTHONIOENCODING=...)`;`stdin` 不接 IDE 的主控台(腳本 `input()` 立刻拿到 EOF);`Popen` 丟 `OSError`(直譯器不見、命令列超過 Windows 上限)時寫進執行視窗並顯示,不從選單拋出 | +| 讀取 | 兩條 daemon Thread 各自經 `queue_pump.read_stream_into_queue()` 對 stdout / stderr `read1()`(有多少讀多少,不等換行:沒換行的狀態列與用 `\r` 重畫的進度條才會即時出現;沒有 `read1` 的文字串流才逐行讀),原樣塞進 `Queue`(保留縮排、行尾與空行);**空讀 = EOF 立刻 break**(否則會 100% CPU 空轉) | +| 送 UI | `QTimer` 每 100 ms 呼叫 `pull_text()`,經 `pump_message_queue()` 每 tick 最多抽 256 則,交給 `CodeWindow.append_output()` | +| 收尾 | 子行程結束後,pump 照樣每 tick 抽 queue,直到兩條 reader 都讀到 EOF 或寬限(`ReaderGrace`,2 秒,那個 tick 還有輸出就重新起算,最長到結束後 30 秒)用完,UI 執行緒不 join 任何執行緒。`exit_program()`:drain queue(`max_messages=None` 一次抽乾)→ reader 還活著(子行程開的行程還握著管線)就在視窗註明之後的輸出不會顯示 → `terminate()` → 呼叫 `task_done_trigger_function`(例如寄信) | +| 停止 | `stop()`:子行程還在跑就 `stop_tree()`(連它開的行程一起:Windows 用 `taskkill /T /F`,POSIX 對子行程自己的 process group 送 SIGTERM;`go run`、`cargo run` 的程式與網頁執行開的瀏覽器是孫行程,只停子行程會留下它們),失敗就退回只 `terminate()` 子行程,之後照一般結束的路徑回報。經 `CodeWindow.stop_runner()` 呼叫;關閉 IDE 時 `PyBreezeMainWindow.closeEvent()` 對每個執行視窗都呼叫一次(經 `_close_guarded()`:一個視窗、分頁或 dock 關閉時丟例外只記錄,其餘照關,JEditor 自己的 `closeEvent` 一定會跑到) | 三種啟動介面: -- `start_test_process(package, exec_str)` — 腳本內容直接走 `--execute_str`(Windows 上先 `json.dumps` 逃逸) +- `start_test_process(package, exec_str)` — 腳本內容直接走 `--execute_str`(Windows 上先 `json.dumps` 逃逸);Windows 上命令列超過 30,000 字元(上限 32,767)時改寫進暫存的 `pybreeze_run_*.json`、走 `--execute_file`(JSON 以全跳脫的 ASCII 寫回,套件用哪種編碼讀都一樣),執行結束或啟動失敗就刪掉 - `start_test_process_file(package, file_path)` — 走 `--execute_file`,避開 Windows ~32K 命令列上限 - `start_module_process(package, arguments, environment)` — 通用形式;**祕密(API key、token)走 environment 不走命令列**,工作管理員看不到 @@ -107,11 +109,12 @@ Template Method 定義的子行程生命週期: | 函式 | 用途 | |---|---| -| `build_process()` | 取當前分頁的程式碼(或傳入的 `exec_str`)→ `start_process()` | +| `build_process()` | 取當前分頁的程式碼(或傳入的 `exec_str`)→ `start_process()`。沒給 `exec_str` 又不是編輯器分頁時,開一個執行視窗寫明「腳本要在前面的編輯器分頁裡」(`report_no_script_tab()`),不會把 `None` 交給套件 | | `start_process()` | 建 `CodeWindow` + `TaskProcessManager` → `start_test_process()` | -| `build_process_from_file()` | 以檔案路徑執行單一檔案 | -| `run_dir_files_with_package()` | 問使用者選資料夾,對每個 `.json` 開一個執行視窗批次跑 | -| `_build_task_process()` | 共用建構:建 `CodeWindow`、掛進 `main_window.current_run_code_window`、決定要不要接 `send_after_test` | +| `build_process_from_file()` | 以檔案路徑執行單一檔案,回傳它的 manager;`then` 在執行結束時呼叫 | +| `run_dir_files_with_package()` | 問使用者選資料夾(`_ask_for_action_files()`,對話框掛在主視窗上;資料夾裡沒有 `.json` 就明說),每個 `.json` 一個執行視窗,一個跑完才跑下一個(`run_one_after_another()`:報告都寫到同一個 `default_name.html`,同時跑會互相蓋掉、寄錯報告);被停止的執行(停止鈕、關閉 IDE)結束整批,啟動不了的檔案跳過 | +| `open_run_window()` | 開一個執行視窗、掛進 `main_window.current_run_code_window`,並接上 `finished_and_closed`:使用者關掉一個已經跑完的執行視窗時主視窗就放掉它(以前這份清單只增不減,每次執行都留下一個視窗、一個執行器、兩個 queue 和一個 timer);執行中被關掉的視窗記下 `_closed_while_running`,等執行器在結束路徑尾端呼叫 `CodeWindow.run_ended()` 時才放掉(排到 timer 的 slot 回來之後才發,視窗是 timer 的 parent)。插件執行也走這裡 | +| `build_task_process()` | 共用建構:`open_run_window()` 並帶上主視窗選定的直譯器、決定要不要接寄報告的 hook(`report_mail_hook(code_window)`:建立時記下子行程工作目錄裡 `default_name.html` 的絕對路徑與開始時間,比這次執行還舊的報告不寄;寄送結果經無 parent 的 `_MailNotice` 以 queued signal 回到執行視窗,寫出寄到了或沒寄的原因)。建好的 `TaskProcessManager` 掛在執行視窗的 `runner` 上,所以呼叫端可以不留參考。prthinker 審查也走這裡 | ### 4.3 各自動化模組(Strategy) @@ -133,29 +136,42 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) | `file_automation` | `automation_file` | | `mail_thunder` | `je_mail_thunder`(只有單一 `call_mail_thunder()`) | -### 4.4 兩個特化執行器 +`test_pioneer/test_pioneer_process_manager.py` 只剩 `init_and_start_test_pioneer_process()`:經 `build_task_process()` 開執行視窗,再用 `start_module_process("test_pioneer", ["-e", ])` 跑,跟其他套件走同一個 `TaskProcessManager`(找不到直譯器時一樣寫進執行視窗,不會從選單 callback 拋出)。 + +### 4.4 特化執行器 -- **`test_pioneer/test_pioneer_process_manager.py`** — `TestPioneerProcess`。跑 `python -m test_pioneer -e `。自帶一份幾乎與 `TaskProcessManager` 相同的 pump / drain / exit 邏輯(只有 `queue_pump` 被抽出共用)。 - **`file_runner_process.py`** — `FileRunnerProcess`。**唯一不跑 Python 的執行器**,服務插件註冊的 run config: - 直譯式:`compiler [args...] file`(如 `go run main.go`) - - 編譯式:`compiler file -o out` → 執行 `out` → 執行完 `os.remove()` 清掉產物 - - 編譯有 60 秒 timeout,QTimer 間隔 50 ms(比 Python 執行器更快) + - 編譯式:`compiler file -o out` → 執行 `out` → 執行完整個建置資料夾刪掉。`out` 建在 `tempfile.mkdtemp()` 開的資料夾裡,不在原始檔旁邊(旁邊同名的檔案會被蓋掉再刪掉,同一個檔案跑兩次也會互搶)。編譯器跟執行一樣走 `_start_process()`(輸出即時串流、不佔 UI 執行緒),結束碼交給 `after_exit`:0 才接著跑產物,否則印 `[Compile failed]` + - 編譯最多 `COMPILE_TIME_LIMIT_SECONDS`(60 秒),由 pump 檢查、超過就 `stop_tree()`;編譯中也能 `stop()`。QTimer 間隔 50 ms(比 Python 執行器更快),編譯與執行共用同一個 + - 讀取、pump、drain 與寫進視窗都用 §4.5 的共用函式 + - `run_current_file_with()` 建好後同樣掛在執行視窗的 `runner` 上;輸出用執行設定的 `"encoding"` 解碼(`output_encoding()`,`"locale"` 表示本機字碼頁,Java 與中文化的編譯器就是輸出本機字碼頁),沒寫就用 IDE 的編碼 + - 有和 `TaskProcessManager` 相同語意的 `stop()` + - 子行程的 `stdin` 是 `DEVNULL`:執行視窗沒有輸入欄,讀取要立刻拿到 EOF,不能卡在沒人寫的管線上 + - 啟動失敗(找不到指令、是資料夾、沒有執行權限、產物被鎖)都寫進執行視窗,建置資料夾經 `_remove_build_dir()` 一併刪掉。`stop()` 會設 `_cancelled`:編譯中或剛編譯完按停止都顯示「[Stopped]」、不執行產物;每個子行程有自己的讀取旗標(`_reading`),編譯的讀取執行緒不會延續到執行階段 + +### 4.5 `queue_pump.py` 與 `CodeWindow.append_output()` -### 4.5 `queue_pump.py` +子行程輸出到執行視窗的整條管線,兩個執行器(`TaskProcessManager`、`FileRunnerProcess`)共用: -抽出來的共用 pump:`MAX_MESSAGES_PER_PUMP = 256`。註解說明得很清楚 — 每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。目前只有 `TaskProcessManager` 和 `TestPioneerProcess` 用它,`FileRunnerProcess` 仍是自己的迴圈。 +- `read_stream_into_queue(stream, queue, buffer_size, encoding, keep_reading)` — reader 執行緒用。行**原樣**進 queue(縮排、行尾、空行都留著);空讀 = EOF 即停,管線被關掉的 `OSError` / `ValueError` 記 debug 後停。超過 `buffer_size` 的長行分段讀進來:用 incremental decoder 解碼(被切斷的多位元組字元接到下一段),段尾的 `\r` 留到下一段(`\r\n` 被切開時不會變成兩個換行);不認得的 encoding 退回 UTF-8 並記 warning +- `pump_message_queue(q, append_fn, is_error, max_messages)` — UI 執行緒用。`MAX_MESSAGES_PER_PUMP = 256`:每 tick 只抽一則的話輸出上限只有 ~10 行/秒,聒噪的腳本會爬行;有上界則避免洪水輸出卡住 UI 執行緒。`max_messages=None` 是收尾時一次抽乾。只跳過空字串 +- `output_queue()` — 每條管線的 queue 最多 `MAX_QUEUED_MESSAGES`(10,000)則;滿了 reader 就等(每 0.2 秒看一次 `keep_reading`),子行程寫管線也跟著等,跑得跟視窗顯示一樣快,像終端機;以前不設上限,印個不停的腳本會一直吃記憶體,按 Stop 後再一口氣全倒進視窗 +- `ReaderGrace` / `any_alive()` / `OUTPUT_STILL_HELD_NOTE` — 子行程結束後 reader 還能讀多久(`READER_GRACE_SECONDS = 2.0`,從結束後第一個 tick 起算)。管線要等最後一個握著它的行程結束才會 EOF,子行程開的行程沒轉向輸出時會一直握著;以前兩個執行器在 UI 執行緒上各 join 2 秒,IDE 卡 4 秒還是丟掉之後的輸出。現在由 pump 逐 tick 詢問,時間到就結束執行並在視窗註明 +- 執行視窗上方有「停止」按鈕:執行器在子行程跑起來後呼叫 `CodeWindow.run_started()` 打開它,`run_ended()` 關掉它,按下去走 `stop_runner()`(`stop_tree` 停掉子行程和它開的所有行程);關掉執行視窗不會停止執行。 +- `CodeWindow.append_output(text, is_error, own_line=False)`(`show_code_window/code_window.py`,輸出是上限 10,000 行的 `QPlainTextEdit`:`QTextEdit` 到上限後每寫一行要花約 15 ms 丟掉最舊的一行)— 一律寫在文件**尾端**(不用 widget 自己的游標:那個游標跟著使用者的點擊與選取走,寫在那裡會把輸出插進中間、或蓋掉使用者選取的文字)。終端機控制碼(CSI 顏色與游標移動、OSC 等控制字串、`ESC ( B` 這類 nF 與其他兩位元組 escape)先拿掉、backspace 套用到前一個字元、tab、換行、`\r` 以外的控制字元丟掉(與 SSH terminal 共用 `utils/terminal_text.strip_terminal_controls()`;讀取切斷在 escape 中間時,reader 把尾巴留給下一段,`queue_pump` 的 `split_incomplete_escape()`),`\r\n` 是換行,單獨的 `\r` 像終端機一樣回到行首、由後面的文字取代這一行(`_insert_rewinding()`;結尾的 `\r` 記在 `_rewind_pending`,等下一段來才套用:接著是 `\n` 就是換行,否則回捲,跑完的進度條不會被清掉);換行只出現在文字本身有換行的地方,所以超過 buffer 被切段的長行會接回同一行。`own_line=True` 給視窗自己的狀態訊息(`Task exit with code …`),程式留下沒換行的半行時先補一個換行。捲軸在最底時畫面跟著輸出走(像終端機);使用者往上捲去讀時就停在原處 --- ## 5. 選單層 `pybreeze_ui/menu/` -`build_menubar.py:add_menu_to_menubar()` 是唯一入口,依序建構 14 個選單建構器。 +`build_menubar.py:add_menu_to_menubar()` 是唯一入口,依序建構 15 個選單建構器。`menu_utils.py` 的 `open_web_browser()` 讓各選單的 Help 連結開成內嵌瀏覽器分頁;`extend_jeditor_tab_menu/jupyter_lab_tab.py` 的 `extend_tab_tools_menu()` 把 JupyterLab 分頁加進 JEditor 的分頁選單。 ### 5.1 `automation_menu_factory.py` — 選單工廠 -`build_automation_menu()` 用宣告式參數組出標準自動化子選單:`Run` 子選單 / `Help`(文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份設定表。 +`build_automation_menu(ui, spec)` 依一份 `AutomationMenu` 描述組出標準自動化子選單:`Run` 子選單(`RunAction` 列表)/ `Help`(`HelpLink` 列表,文件+GitHub,開內嵌瀏覽器分頁)/ `Project`(建立範本目錄)/ GUI 分頁,每一段各由一個小函式建(`_add_run_menu` 等),沒有項目的段落不建。三個描述都是 frozen dataclass。六個自動化模組全部靠它,`build_*_menu.py` 只剩一份 `AutomationMenu(...)`。每個 QAction 都以它所在的選單為 parent,由 Qt 持有;AutoControl 額外的 `Record` 子選單也一樣;它的停止錄製不論前面是哪個分頁都會停,把動作以 AutoControl 執行器讀的 JSON 插在編輯分頁的游標處(沒有編輯分頁就放剪貼簿),沒錄到東西就告知。 -`safe_create_project(import_name)` 回傳延遲 import 的 closure,模組沒裝時只記 log 不炸選單。 +`safe_create_project(ui, import_name)` 回傳延遲 import 的 closure:專案建在 IDE 開著的資料夾(`working_dir`,沒開就用行程的工作目錄);套件的資料夾(`create_project_dir` 的 `parent_name` 預設值)已存在時先問(預設否),因為各套件一律覆寫範本檔;模組沒裝、寫入失敗都跳警告並記 log,成功時說出建在哪裡。 | 選單 | 文件 | GUI 分頁 | |---|---|---| @@ -168,27 +184,27 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ### 5.2 非工廠的兩個選單 -- **`test_pioneer_menu/`** — 建範本目錄 + `QFileDialog` 選 `.yml`(會驗副檔名,選錯跳 `QMessageBox`) -- **`prthinker_menu/`** — 審查目前檔案 / 審查 PR(`QInputDialog` 問編號,範圍 1–1,000,000)/ 設定對話框 / Help +- **`test_pioneer_menu/`** — 建範本目錄(寫在 IDE 的工作目錄,已有範本先問是否取代,寫入失敗跳警告)+ `QFileDialog` 選 `.yml` / `.yaml`(副檔名清單與語法高亮共用 `syntax_keyword.TEST_PIONEER_SUFFIXES`;會驗副檔名,選錯跳 `QMessageBox`) +- **`prthinker_menu/`** — 審查目前檔案(先照 Run with... 的方式存檔:`save_current_file_for_run()`)/ 審查 PR(`QInputDialog` 問編號,範圍 1–1,000,000)/ 設定對話框 / Help ### 5.3 `tools/tools_menu.py` — 表格驅動的工具註冊 -這是全專案設計最乾淨的一塊。三張表把 19 個工具的「建構」「分頁開啟」「dock 開啟」完全解耦: +這是全專案設計最乾淨的一塊。三張表把 20 個工具的「建構」「分頁開啟」「dock 開啟」完全解耦: - `_WIDGET_FACTORIES: dict[str, Callable]` — widget key → 建構 lambda - `_TAB_ACTIONS: tuple[...]` — (widget key, 主視窗屬性, 選單屬性, action 語言鍵, 分頁標籤鍵) -- `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock +- `_DOCK_ACTIONS` / `_DOCK_TITLES` — 同一組 widget 也能開成右側 dock(`closing.AskingDock`:關 dock 前先問 widget 的 `may_close()`,有未存變更的提示詞與架構圖編輯器不會被 dock 的關閉鈕直接丟掉) -`_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。 +`_register_action()` 有一段關鍵註解:QAction 必須 `setattr` 掛回主視窗,否則 Qt 不持有它、被 GC 後選單項就失效。另一種做法是建構時把選單當 parent(自動化選單工廠、插件選單用這種)。`test_started_menus.py` 在子行程啟動真的 IDE、GC 後走訪整條選單列,任何子選單變空就失敗(JEditor 的兩個字型選單除外:offscreen 平台沒有字型)。 ### 5.4 插件選單 -- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 每個副檔名一個 Run 動作);另有「Plugin Browser」分頁入口 -- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。執行前會強制存檔、驗副檔名,再交給 `FileRunnerProcess` +- **`build_plugin_menu.py`** — 讀 `je_editor.plugins.get_all_plugin_metadata()`,每個插件一個子選單(About + 一個 Run 動作,多個副檔名時一併列在標籤裡,動作直接呼叫 `run_current_file_with()`);另有「Plugin Browser」分頁入口。插件是第三方程式:不是 dict 的 metadata 或 run config 略過並記 log,名稱經 `plugin_text()` 轉成文字(`addMenu(None)` 會讓 Qt access violation),每個插件的選單各自建、失敗只少它自己那一項 +- **`build_run_with_menu.py`** — 讀 `get_all_plugin_run_configs()`,在 Run 選單下加「Run with…」。`run_config_suffixes()` 把插件登記的副檔名正規化成 `Path.suffix` 的樣子(小寫、一個前導點;JEditor 原樣保存,`.R`、`r` 以前永遠比對不上)。`run_current_file_with()` 先經 `save_current_file_for_run()` 存檔(已有檔名的分頁照 JEditor 自己存檔的方式寫:`write_file_with_encoding()` 用分頁的編碼與行尾,寫成功後才 `mark_ignore_next_file_change()` 與 `mark_saved()`;存檔失敗跳警告、不執行;沒檔名的走 JEditor 的另存新檔),再驗副檔名、交給 `FileRunnerProcess`。Plugins 選單的 Run 動作也走這一條 ### 5.5 安裝選單 -`install_utils.install_package()` 借用 JEditor 的 `ShellManager` 跑 `pip install -U`,輸出進 shell 面板。 +`install_utils.install_packages()` 用 `build_task_process()` 開一個執行視窗,`start_module_process("pip", ["install", "-U", *packages])`:參數清單、不經 shell(以前借 JEditor 的 `ShellManager`,它用 `shell=True` 交給 `cmd.exe`,使用者選的資料夾名稱裡有 `&` 就會把指令切開)。多個套件一次 pip(建置工具以前是三個 pip 同時對同一個環境跑)。pip 用 IDE 選定的直譯器,沒選時照一般執行的退路。`install_package()` 是單一套件的寫法 - `automation_menu/` — 七個自動化套件的一鍵安裝。**prthinker 例外**:不在 PyPI 上,第一次會問來源資料夾、記進設定,之後裝 `[runner]` - `tools_menu/` — 安裝 setuptools / build / wheel @@ -203,39 +219,39 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) |---|---|---| | `CurlImportGUI` | `utils/curl_import/` | 貼上 curl 指令 → 產生 requests / pytest / APITestka(py & json) / LoadDensity 腳本 | | `HarImportGUI` | `utils/har_import/` | 開 `.har` → 列出錄到的請求(可只看 API-like)→ 批次產生腳本 | -| `JwtDecoderGUI` | `utils/jwt_tools/` | 解 JWT header/payload(不驗簽),時間戳轉可讀 UTC | -| `TimestampGUI` | `utils/timestamp_tools/` | epoch(自動判秒/毫秒)↔ ISO-8601 | +| `JwtDecoderGUI` | `utils/jwt_tools/` | 解 JWT header/payload(不驗簽),時間戳轉可讀 UTC;貼上的文字先去掉空白,不是單純的 token 就取出裡面第一個(`Bearer `、引號、換行都可以),base64url 嚴格解碼 | +| `TimestampGUI` | `utils/timestamp_tools/` | epoch(依大小自動判秒/毫秒/微秒/奈秒;整數用 `int()`、小數用 `Decimal` 精確換算,一律往過去截到微秒)↔ ISO-8601(`_ISO_RE` 自己解析,3.10 到 3.14 讀法一致:`Z`/`z`、`±HH`、`±HHMM`、任意位數小數、basic 格式;八位數而且是合法日期就當 `YYYYMMDD`)。epoch 換算用 `utc_from_epoch_seconds()`(epoch + `timedelta`;`datetime.fromtimestamp` 在 Windows 上拒絕 1970 年前幾小時以外的值),JWT 的時間戳 claim 也用它 | | `HashGUI` | `utils/hash_tools/` | 多演算法摘要 | | `QueryJsonGUI` | `utils/query_tools/` | query string ↔ JSON 雙向 | | `UrlBuilderGUI` | `utils/url_tools/` | URL 拆成 JSON 元件 / 由元件組回 URL | -| `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組 | +| `RegexGUI` | `utils/regex_tools/` | regex 測試,flag 勾選、列出每個 match 與群組。pattern 在另一個行程裡跑(`find_matches_bounded()`:從原始碼執行時是 `python -I -S -c` 跑一段只用標準函式庫的固定腳本,工作用 JSON 從 stdin 進、結果從 stdout 出,5 秒後 kill;打包版沒有直譯器可用,仍是 multiprocessing spawn),分頁用 `RegexMatchThread` 等它:`re` 開始比對後就停不下來,災難性回溯只有整個行程能停。spawn 會重新匯入啟動 IDE 的腳本,README 那種沒有 `__main__` 防護的腳本會每跑一次就再開一個 IDE。還在跑的 worker 記在 `_RUNNING`,分頁關閉時 `stop_running_workers()` 結束它(IDE 以 `os._exit` 結束,子行程不會跟著走);執行中不能存檔,列到 `MAX_MATCHES` 上限時會註明可能還有更多 | | `HttpStatusGUI` | `utils/http_reference/` | 狀態碼參考,可依碼前綴或描述搜尋 | -| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計 | +| `DiffGUI` | `utils/diff_tools/` | unified diff + 增刪統計,`compare_texts()` 的統計與 diff 共用同一次比對(`_TrimmedMatcher`:先把相同的開頭結尾放一邊再比對中間,長而重複的文字改一行就是一行;放一邊有時反而比對得更差(`b b a b a` 對 `b a c b`),所以有放一邊時 `_closest_match` 也照原樣比一次,取改動行數少的;autojunk 照 difflib 的預設,關掉的話重複的文字比對時間隨行數平方成長;diff 照 `difflib.unified_diff` 的格式從它的 grouped opcodes 寫出),在 `DiffThread` 上算、不佔 UI 執行緒(4 萬行要四秒多),比對中按鈕停用、關閉時交給 `let_run_out()`。逐行比不出差別、文字卻不同時(最後少一個換行、`\r\n` 對 `\n`),改成連行尾一起比:少換行的那行下面標 `\ No newline at end of file`,其他行尾寫出來 | | `JsonFormatGUI` | `utils/json_format/` | 美化 / 壓縮 / 驗證 | | `HeaderAnalyzerGUI` | `utils/header_tools/` | HTTP header 安全稽核(HSTS、CSP、CORS、Set-Cookie、banner…)| -| `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT | +| `ResponseInspectorGUI` | `utils/response_inspector/` | 貼整包 response → 拆狀態列/headers/body,順便挖出 JWT;`curl -i` 印出的多段回應(`100 Continue`、proxy 的 `Connection established`、`-L` 的轉址)取最後一段;只有一行又沒有狀態列就當 body | ### 兩個橫向共用機制 - **`tool_tabs.open_tool_tab()`** — 工具之間互相「轉交」:Response Inspector 把狀態碼丟給 HTTP Status、headers 丟給 Header Analyzer、JWT 丟給 JWT Decoder、JSON body 丟給 JSON Format;curl 匯入把 URL 丟給 URL Builder。開新分頁並自動聚焦。 -- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上;副檔名與檔名可傳 callable 動態決定。 +- **`output_actions.OutputActions`** — 統一的「複製 / 在編輯器開啟 / 存檔」三顆按鈕,綁在工具的唯讀輸出 `QTextEdit` 上,輸出也經 `exact_text()` 讀(不讓 U+00A0、U+2028 被改掉);副檔名與檔名可傳 callable 動態決定。存檔經 `replace_text()` 整檔替換,失敗(唯讀資料夾、被鎖住的檔案、磁碟滿)時原檔不動、會跳警告,說出檔名與原因。Qt 的文字元件留不住貼上的 CR,換行一律讀成 LF --- -## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,310 行,最大子系統) +## 7. `pybreeze_ui/diagram_editor/` — 架構圖編輯器(3,963 行,最大子系統) | 檔案 | 職責 | |---|---| -| `diagram_editor_widget.py` (584) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵;PNG/SVG 匯出;Mermaid 匯入對話框 | -| `diagram_scene.py` (648) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()` | -| `diagram_items.py` (830) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範) | -| `diagram_mermaid_parser.py` (520) | Mermaid flowchart → diagram dict。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | -| `diagram_property_panel.py` (421) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單 | -| `diagram_view.py` (175) | `QGraphicsView`:滾輪縮放(有上下界)、中鍵平移、`drawBackground` 畫格線 | -| `diagram_commands.py` (28) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態 | -| `diagram_net_utils.py` (104) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗、20 MB 大小上限、15 秒 timeout | +| `diagram_editor_widget.py` (678) | 外層 widget:兩排工具列(工具模式列 + 檔案/undo/對齊/格線/匯出/縮放列)、canvas 與屬性面板的 splitter、快捷鍵(只在編輯器有焦點時作用,當 dock 開著也不搶程式碼編輯器的按鍵);PNG/SVG 匯出;Mermaid 匯入對話框。「從 URL 加入圖片」也交給 `ImageDownloadThread`,圖片回來才放上畫布,失敗或不是圖片就跳警告;關閉時還在跑的下載交給 `let_run_out()`。存檔經 `replace_text()` 先寫 `.saving` 再換上去,存檔失敗不會毀掉上一份 | +| `diagram_scene.py` (888) | `DiagramScene(QGraphicsScene)`:**State pattern** 的 `ToolMode` 決定滑鼠行為;undo/redo、複製貼上(節點、連線與圖片)、多選對齊與分佈、z-order、序列化 `to_dict()` / `load_from_dict()`。`get_all_nodes/connections/images()` 由下往上列出(`_bottom_first()`),存檔與 undo 還原時同 z 值的重疊項目維持原本的上下;右鍵選單先選取點到的項目;置頂/置底放到所有其他節點與圖片之上/之下;`to_dict()` 給每個節點與圖片記下它在兩者之間由下往上的位置(`stack`),載入後 `_restore_stacking()` 照這個順序重新加回場景(同 z 值時後加的在上面),圖片也存 `z`。`load_from_dict()` 先用 `_check_is_a_diagram()` 確認資料形狀才清空畫布(不合就丟 `ValueError`,畫布原封不動),每一筆節點/連線/圖片再各自容錯;清空前先 `to_dict()` 留一份,載入途中還是出錯就放回原樣再往上丟——載入要嘛成功、要嘛什麼都沒變(編輯器存檔寫回上次開的檔案,半途清空的畫布會蓋掉使用者的檔)。圖片的 `source` 不是字串就丟掉。`undo_scope` 用 `try/finally`,本體丟例外也一定收掉快照;圖片來源先看副檔名、拒絕 UNC(`_is_on_this_machine()`)才碰檔案系統;URL 圖片交給 `ImageDownloadThread(QThread)` 下載並快取在 `_pixmap_cache`,undo/redo 重建項目時直接用快取,不會再連一次網路;編輯器關閉時 `let_image_downloads_run_out()` 把還在跑的下載交給 `let_run_out()`,不在 UI 執行緒等 | +| `diagram_items.py` (960) | 圖元:`DiagramNode`(矩形/圓角/橢圓/菱形 4 種 body + 置中標籤 + 4 個 `ResizeHandle`;填色、框線色、字級收在 frozen dataclass `NodeStyle`)、`DiagramConnection`(三次貝茲 + 箭頭,連到節點邊界交點)、`DiagramImage`。`_EditableLabel` 刻意預設唯讀、雙擊才進編輯(對應 CLAUDE.md 的 Qt 規範);雙擊時記下場景快照,失去焦點時經 `DiagramScene.record_change()` 記成一步「Edit Text」undo(有改才記)。`DiagramScene.add_image()`(`diagram_scene.py`)把圖放進 scene 的 `_pixmap_cache`,undo 重建時不必重讀檔案或重新下載。檔案裡的字級經 `_clamped_font_size()`:不是有限數字(`1e999` 讀進來是無限大、NaN、字串)就用預設字級;位置經 `_coordinate()`:不是有限數字就跳過這一筆,超過 `MAX_COORDINATE`(一百萬)就夾回來;連線建好所有東西之後才掛到兩端節點上 | +| `diagram_mermaid_parser.py` (603) | Mermaid flowchart → diagram dict。切箭頭與 `;` 之前先用 `_protect()` 把引號與括號裡的標籤換成佔位符,解析節點時再 `_restore()`(標籤裡的 `-->`、`;` 不會被當成語法)。含 **Sugiyama 風格自動排版**:分層 → 交叉最小化掃描 → 交叉軸偏移解析 | +| `diagram_property_panel.py` (453) | 右側屬性側欄,依選取型別切換 node / connection / image 三組表單;每記一步 undo(場景的 `recorded`)就重新整理(在畫布上拖把手改大小時選取沒變);寬、高各自只改自己那一邊;數字欄位不追鍵盤、輸入完才套用 | +| `diagram_view.py` (195) | `QGraphicsView`:滾輪與按鈕縮放都經 `_step_zoom()`(有上下界,界外時仍可往界內走;橫向滾輪不縮放),`fit()` 把「符合視窗」夾在上下界內、中鍵平移、`drawBackground` 畫格線 | +| `diagram_commands.py` (48) | `DiagramSnapshotCommand(QUndoCommand)` — 快照式 undo,存變更前後完整場景狀態;有 `merge_key` 的連續步驟(同一個項目的同一個屬性:大小、字級、線寬)合併成一步(`id()` / `mergeWith`) | +| `diagram_net_utils.py` (112) | **SSRF 防護參考實作**:scheme 白名單、DNS 解析後比對私有/迴環/link-local/reserved 網段、`_ValidatingRedirectHandler` 對每一跳重驗(驗過就關掉轉址回應,urllib 不會把轉址的內容整個讀完)、`_OPENER` 用 `PublicHTTPHandler` / `PublicHTTPSHandler`(連線當下再檢查一次並只連到那個位址)、20 MB 大小上限、每次等資料 15 秒 timeout,整個下載另有 `overall_deadline(DOWNLOAD_DEADLINE_SECONDS)` 120 秒上限(慢慢送位元組的伺服器原本可以一直卡住下載執行緒) | -`diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py`。 +`diagram_net_utils` 是 CLAUDE.md 指定的網路安全參考實作,其他 HTTP 呼叫端則統一走 `utils/network/url_validation.py` 驗證、經 `utils/network/public_http.py` 的 `public_session()` 送出。 --- @@ -244,19 +260,19 @@ call_X_multi_file_and_send() → run_dir_files_with_package(..., True) ``` extend_ai_gui/ ├── ai_gui_global_variable.py 模板檔名清單 + 檔名→內建模板內容對照表 -├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/) +├── prompt_store.py 編輯過的 prompt 檔案解析(~/.pybreeze/prompts/;`read_prompt_file()` 讀 utf-8-sig,非 UTF-8 視同讀不到、退回內建;讀取時什麼都不建立(`pybreeze_data_path()`),查檔丟 `OSError` 也退回內建) ├── code_review/ │ ├── cot_chain.py 接線表(純邏輯,無 Qt):哪步引用哪步 │ ├── code_review_thread.py SenderThread(QThread):跑八步審查鏈 -│ └── cot_code_review_gui.py UI +│ └── cot_code_review_gui.py UI(工具 → AI 的分頁與 dock);URL 只由 worker 驗證,UI 執行緒不查 DNS;每次送出先清掉上一輪的回覆;關閉時請審查停在目前這一步,交給 let_run_out(),不等 ├── prompt_edit_gui/ -│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新) +│ ├── prompt_editor_widget.py 共用編輯器(QFileSystemWatcher 熱更新,watcher 以編輯器為 parent、關閉時停止監看;有未存編輯時,外部改動、「重新載入」和切換模板都先問;還沒有檔案的模板只用 placeholder 說明,存檔不會把說明寫進去) │ ├── cot_prompt_editor_widget.py 8 個 CoT 模板的檔案清單+語言鍵 │ ├── skills_prompt_editor_widget.py 2 個 Skill 模板的檔案清單+語言鍵 -│ ├── prompt_file_io.py 共用存檔(失敗跳警告對話框) +│ ├── prompt_file_io.py 共用存檔(經 `replace_text()`:先寫 `.saving` 再換上去;失敗跳警告對話框,不顯示路徑) │ ├── cot_code_review_prompt_templates/ 8 個模板常數+global_rule │ └── skills_prompt_templates/ 2 個模板常數 -└── skills/skills_send_gui.py 單次 prompt 發送(RequestThread) +└── skills/skills_send_gui.py 單次 prompt 發送(RequestThread:回答走 `answered`,不再蓋掉 QThread 自己的 `finished`;關閉時交給 `thread_keeper.let_run_out()`;換模板前編輯區有修改就先問;prompt 還留著 `{code_diff}` 就不送出) ``` **CoT 審查鏈**(`cot_chain.py` 定義接線,`code_review_thread.py` 執行)八個步驟: @@ -271,35 +287,41 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 `cot_chain.py` 用兩張表描述接線:`STEP_RESULT_KEY`(每步答案存在哪個 key)與 `STEP_ARGUMENTS`(每步的 placeholder 由哪個 key 填)。**順序即相依順序** —— 每步只能引用它上面的步驟,`test_cot_chain.py` 有結構性測試守住這件事。步驟失敗時錯誤訊息只顯示給使用者、不會被存進 results,避免後續步驟把「傳送失敗」當成審查內容引用。每步都套 `build_global_rule_template()` 包一層全域規則。 -安全處理:送出前 `validate_url()`、`allow_redirects=False`、`stream=True` 搭配 `read_capped_text()` 限制回應大小、單一 `requests.Session` 重用 TCP/TLS 連線、`isInterruptionRequested()` 讓 widget 關閉時能中止。 +安全處理:送出前 `validate_url()`、`allow_redirects=False`、`stream=True` 搭配 `read_capped_text()` 限制回應大小、非 2xx(`succeeded()`)算這一步失敗、不會被後面的步驟引用、單一 `requests.Session` 重用 TCP/TLS 連線、`isInterruptionRequested()` 讓 widget 關閉時能中止。 --- ## 9. `pybreeze_ui/connect_gui/` -### `ssh/`(1,116 行) +### `ssh/`(2,035 行) | 檔案 | 職責 | |---|---| -| `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端 | -| `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`) | -| `ssh_command_widget.py` | 互動式 shell。`SSHReaderThread(QThread)` 輪詢 channel,ANSI escape 用 regex 剝除,terminal 有 block 上限,keepalive | -| `ssh_file_viewer_widget.py` (572) | `SFTPClientWrapper` + `SSHFileTreeManager`:延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳)、目錄優先 + 自然排序 | -| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU) | -| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的 | +| `ssh_main_widget.py` | 組合視圖:上方共用登入表單,下方 splitter 左 30% 檔案樹、右 70% 終端。兩半各自連線、各自發 `state_changed`,共用的狀態列每次有一半連上或斷開就重報兩者的狀態(不是按下按鈕時)。`closeEvent` 把關閉往下傳給兩半(Qt 只會送給被關的那個 widget) | +| `ssh_login_widget.py` | 登入表單(密碼欄用 `EchoMode.Password`;任一欄按 Enter 就按下連線;金鑰欄旁的「瀏覽...」從 `~/.ssh` 開檔案對話框,選了檔就勾起金鑰驗證) | +| `ssh_command_widget.py` | 互動式 shell。連線和開 shell 的 channel(`open_shell_channel()`:開 session 最多等 paramiko 的 `channel_timeout` 一小時,pty 與 shell 沒有逾時)都在 `SshConnectThread` 上做,UI 執行緒只接手啟動 reader;連線中再按 Connect 不理,連線中關掉 widget 時執行緒交給 `let_run_out()`,晚到的連線一結束就關掉。`SSHReaderThread(QThread)` 輪詢 channel(shell 結束時先把緩衝裡剩下的讀完;伺服器那端結束 shell 時 `_on_closed()` 一樣 `_cleanup()` 關掉連線並發 `state_changed`,共用狀態列照兩半的實際狀態重報),`TerminalDecoder` 把每次讀到的 bytes 轉成文字:UTF-8 字元、escape 與結尾的 `\r` 被讀取切斷時留到下一次接上(`split_unfinished_end()`,`\r\n` 被切開不會多一行空行),escape(CSI、OSC/DCS/SOS/PM/APC 控制字串、`ESC ( B` 這類 nF、其他兩位元組 escape)用 regex 剝除,backspace 套用到前一個字元、其餘 C0 控制字元丟掉(`utils/terminal_text.py` 的 `strip_terminal_controls()`;切斷的 escape 由 `split_incomplete_escape()` 留到下一次);送出指令走 `send_all()`:整串 UTF-8 bytes 用 `sendall` 送完(`Channel.send` 一次只送一個封包),只在這次送出時給 channel 5 秒逾時;輸出接在最後一行後面(不用 `appendPlainText`,那會讓每次讀取都另起一行),自己的提示訊息才另起一行,terminal 有 block 上限,keepalive。`closeEvent` 一律 `_cleanup()`:執行緒不能活得比 widget 久(QThread 還在跑就被銷毀會讓 Qt abort) | +| `ssh_file_viewer_widget.py` (722) + `sftp_session.py` (473) | `SSHFileTreeManager`(樹與右鍵選單,只看執行緒的 signal 做事)+ `sftp_session.py` 的 `SFTPClientWrapper`、`SftpListThread` / `SftpTransferThread` / `SftpCallThread` 與純路徑工具(`remote_join`、`plain_remote_name`、`sort_entries`):延遲載入的遠端檔案樹、右鍵選單(重新整理/建資料夾/改名/刪除/下載/上傳;新名稱只能是單一項目,`plain_remote_name()` 擋掉 `/`、`.`、`..`;改名已載入的資料夾會用新路徑重列子項;從檔案項目建資料夾或上傳時重新整理它所在的資料夾,`folder_item()`)、目錄優先 + 自然排序(項目是資料夾還是檔案記在 `KIND_ROLE`,`is_folder()` / `is_file()` 讀它,不看類型欄的字);列目錄時符號連結用 `stat` 換成它指向的東西的類型與大小(`_follow_link`,伺服器列目錄用的是 `lstat`,連到資料夾的連結原本當成檔案),指不到東西的連結照舊當檔案。每個 SFTP 操作都經 `_session()`:拿 `_in_use` 鎖(paramiko 的 SFTP client 會把別的執行緒的回覆讀走丟掉,送出那個請求的執行緒就永遠等下去)、再 `_require_connection()`;列目錄與傳輸在工作執行緒上一直等,右鍵選單的建資料夾/改名/刪除交給 `SftpCallThread`(SFTP 回覆沒有逾時,放在 UI 執行緒會凍到 TCP 放棄),等 session 最多 `UI_WAIT_SECONDS`(1 秒),等不到就丟 `SftpBusy`(「連線忙碌」),完成後才更新樹(樹被清掉就不動);下載先寫到同資料夾的暫存檔(`.<檔名>.*.part`),完整了才 `os.replace` 換上,失敗就刪掉暫存檔、原檔不動;上傳同理:先 `stat` 目標,已存在又沒說要取代就什麼都不傳、發 `exists` 讓樹先問(預設「否」),再 `put` 到 `.<檔名>.<亂數>.part`,完整了才 `posix_rename`(伺服器沒有這個擴充就先刪再 `rename`)換上;連線交給 `SshConnectThread`,成功後才列出根目錄;`SFTPClientWrapper.connect()` 用區域變數建連線,登入完如果 wrapper 已經被 `close()`(Disconnect 或關分頁)就自己關掉這條連線、丟 `ConnectAbandoned`,失敗時也只關自己的;連線中按 Disconnect 會把連線執行緒交給 `let_run_out()`(不跳「連線失敗」),可以再按 Connect;每次列目錄(根目錄、展開、重新整理)交給 `SftpListThread`,先顯示「載入中」,結果回來時只在樹沒被清掉(`_tree_generation`)、而且該項目等的還是這一次(`LISTING_ROLE` 序號,重新整理會取代前一次)時才填進去;下載/上傳交給 `SftpTransferThread(QThread)`(傳輸沒有自己的逾時,跑在 UI 執行緒會把整個 IDE 凍到傳完),一次只允許一個,傳輸中右鍵選單多一項「取消傳輸」(`SftpTransferThread.cancel()`:paramiko `get` / `put` 的進度回呼每塊都檢查,丟 `TransferCancelled`,暫存檔照失敗處理刪掉、要被取代的檔案不動,發 `cancelled`);傳輸中按 Connect 不重連檔案樹(連線一開始就 `close()`,會把傳輸砍斷),只提示正在傳輸,`_refused_while_transferring()`;`closeEvent` 不等它也不打斷它(打斷會留下半個檔案),交給 `let_run_out()`,傳完才關掉 SFTP 連線 | +| `ssh_host_key_policy.py` | **`InteractiveHostKeyPolicy`** — 取代 `AutoAddPolicy`。首次連線顯示 SHA256 指紋要使用者確認,確認後寫入 `~/.pybreeze/ssh_known_hosts`(TOFU)。查詢、詢問、寫入都在模組層的 `_DECISION_LOCK` 裡一次一個(只有連線執行緒會拿,UI 執行緒不等它);問之前先重讀檔案(同一次 Connect 的另一半剛接受過就不再問),使用者拒絕的 (host, 指紋) 記 10 秒,另一半直接拒絕;寫入時在檔案現況後面加一行(`_store()`),不用 `client.save_host_keys()`(那會用 Connect 時讀到的舊副本蓋掉別的分頁剛接受的主機),也不用 `HostKeys.save()`(paramiko 讀不懂的行,壞行或 `ssh-dss`,會被寫掉)。兩個 known_hosts 都經 `load_known_hosts()` 逐行讀,讀不懂的行跳過(`HostKeys.load` 遇到非 base64 的 key 丟 `InvalidHostKey`,整個 Connect 就失敗)。問題由 `HostKeyAsker`(住在 UI 執行緒的 QObject,`host_key_asker()` 取得,兩個 SSH widget 建立時先建好)顯示:從連線執行緒問時走 `BlockingQueuedConnection`,連線執行緒等答案、UI 不等。每個問題都從「否」開始;發問的面板已經關掉(dock 關閉即刪除)就答「否」,不會沿用上一題的答案 | +| `ssh_connect_thread.py` | `SshConnectThread(QThread)`:在自己的執行緒上跑一次會阻塞的 `connect()`,發 `connected` 或 `failed(message)`。`CONNECT_ERRORS` 是連線會丟的例外;`SHA1_ALGORITHMS` 是每個 `connect()` 都帶上的 `disabled_algorithms`,拒絕 SHA-1 的 RSA 簽章與金鑰交換(paramiko 5 已移除,paramiko 4 仍會提供;CVE-2026-44405)。TCP 10 秒、banner 15 秒、auth 30 秒逾時加起來,連不到的主機以前會把 IDE 凍住將近一分鐘 | +| `ssh_key_loader.py` | 依序嘗試各種私鑰型別,回傳第一個能解析的;都不行時 `unloadable_key_reason()` 分辨是密語沒給/給錯(檔案有加密,而且給的密語解不開它;用 `cryptography` 試解)還是不支援的私鑰(例如加密的 DSA) | ### `url/ai_code_review_gui.py` -獨立的 HTTP client widget:送出程式碼給審查端點、接受/拒絕回覆並記錄統計到 `~/.pybreeze/response_stats.txt`、URL 歷史存 `urls.txt`。 +獨立的 HTTP client widget:送出程式碼給審查端點、接受/拒絕回覆並記錄統計到 `~/.pybreeze/response_stats.txt`(開啟時用 `read_stats()` 讀回上次的總數接著算;檔案不是這個格式或讀不了就從 0 開始)。 + +- 請求走 `ReviewRequestThread(QThread)`,只有 `answered` / `failed` 兩個 signal 碰 UI(和 `SkillsSendGUI` 同一套);送出中再按不會重送;`closeEvent` 不等它,交給 `thread_keeper.let_run_out()`:斷開它和面板的連線、留著參考直到它結束(等它會讓 IDE 凍住最長一個讀取逾時) +- `urls.txt` 只存 URL 的 SHA-256 指紋(`url_fingerprint()`):API URL 可能帶權杖,依 CLAUDE.md 要當憑證看待;舊版留下的明文檔會在下次送出時改寫成指紋 +- 非 2xx(含不跟隨的轉址)走 `failed`,狀態碼寫進面板,不會只留空白 +- 接受/拒絕只在收到回答(`answered`)後可按,每個回答只能評一次;Send 按鈕由執行緒的 `finished` 恢復,請求不論怎麼結束都回得來 --- ## 10. `pybreeze_ui/jupyter_lab_gui/` -- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `get_venv_python()` → `is_jupyter_installed()`(缺就自動裝)→ 啟動 server → `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)` -- `jupyter_lab_widget.py` — 收到 URL 後用 `QWebEngineView.setUrl()` 載入;`closeEvent` 負責關掉 server +- `jupyter_lab_thread.py` — `JupyterLauncherThread(QThread)`:`find_free_port()`(綁 127.0.0.1 讓核心挑空 port)→ `choose_python()`(IDE 選定的直譯器優先,其次 venv 的,最後 IDE 自己的)→ `is_jupyter_installed()`(問直譯器 `find_spec('jupyterlab')`,不問 pip;缺就自動裝)→ 啟動 server(`_start_server()`,在 `_process_lock` 裡先看 `_stopped`:`stop()` 之後就不再啟動,即使還在安裝)→ `_wait_until_ready()` 輪詢 port(60 秒 timeout)→ emit `server_ready(url)`。server 的輸出寫進暫存檔而不是管線(server 起來後沒人讀管線,緩衝區滿了它會卡在 `write()`);提早結束時錯誤訊息取這個檔案的尾巴。失敗時 `error_occurred` 送的是原因(例外訊息,最多 2,000 字),traceback 只進 log;已經 `stop()`(分頁關了)的失敗不算失敗,只記 debug +- `jupyter_lab_widget.py` — 設 `WA_DeleteOnClose`:分頁關閉就刪掉(`close_tab` 只移除分頁、不刪 widget,網頁檢視與它的 Chromium renderer 會一直留到 IDE 結束)。收到 URL 後用 `QWebEngineView.setUrl()` 載入;失敗時在狀態列顯示「初始化失敗:原因」(純文字、可選取、自動換行);`closeEvent` 一律關掉 server(launcher 執行緒在 lab 載入完就結束了,只停「還在跑的執行緒」等於從不停 server);還在安裝或啟動的 launcher 交給 `let_run_out()`,不在 UI 執行緒等(安裝可能要好幾分鐘),也不用 `blockSignals`(那會連 `finished` 一起擋掉,keeper 永遠放不掉它)。IDE 關閉時 `PyBreezeMainWindow._close_tool_tabs_and_docks()` 會關掉所有非編輯器分頁與 `DestroyDock`,這個 `closeEvent` 才會被呼叫到 -安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。 +安全前提(CLAUDE.md 已明列):server 只綁 localhost,因此 token/password 刻意留空、`disable_check_xsrf=True` 才能內嵌。`--ServerApp.port_retries=0`:port 被占就直接結束(走「提早結束」的回報),不讓它默默換 port。**不設 `allow_origin`**:loopback 擋不住瀏覽器,開放來源的話使用者逛到的任何網頁都能操作這個沒有 token 的 server。 --- @@ -307,36 +329,40 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 - `syntax_keyword.py`(625 行)— 七份關鍵字清單,彙整成 `package_keyword_list`: `je_auto_control` / `je_load_density` / `je_api_testka` / `je_web_runner` / `automation_file` / `mail_thunder` / `test_pioneer` -- `syntax_extend.py` — 把前六個註冊到 `.json`(黃色 `#FFFF00`),`test_pioneer` 註冊到 `.yml`(橘色 `#FF9900`),然後重置當前編輯器的 highlighter +- `syntax_extend.py` — 把前六個註冊到 `.json`(黃色 `#FFFF00`),`test_pioneer` 註冊到 `TEST_PIONEER_SUFFIXES` 的每個副檔名(`.yml`、`.yaml`,橘色 `#FF9900`),然後重置當前編輯器的 highlighter `PackageManager.syntax_check_list` 決定要註冊哪些;用 `package_keyword_list.get(pkg, [])` 取值,套件沒有關鍵字清單時註冊空集合而不是炸掉。 --- -## 12. `pybreeze/utils/` — 基礎工具(14 個子套件) +## 12. `pybreeze/utils/` — 基礎工具(18 個子套件) | 套件 | 內容 | |---|---| -| `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置 | +| `app_dirs.py` | `pybreeze_data_dir()` → `~/.pybreeze`,所有持久化資料的單一位置,建立時為 `0700`(`DATA_DIR_MODE`);`pybreeze_data_path()` 只給路徑、不建立 | +| `terminal_text.py` | 終端輸出的 escape 與控制字元:`strip_terminal_controls()`(CSI、OSC/DCS 等控制字串、nF、兩位元組 escape 剝除,backspace 套用,其餘 C0 丟掉)、`split_incomplete_escape()`(讀取切斷在 escape 中間時把尾巴留給下一次)、`split_unfinished_end()`(再加上它前面或最後的 `\r`)、`take_leading_backspaces()`(一段開頭的 backspace 留給畫面,擦掉前一段已經顯示的字,不越過行首)。SSH terminal 與執行視窗共用 | | `subprocess_util.py` | `utf8_subprocess_env()`(釘 `PYTHONIOENCODING`,解 Windows cp950 亂碼)、`no_window_creationflags()`(`CREATE_NO_WINDOW`,避免 GUI 程式彈出黑窗) | -| `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`,上限由 `PYBREEZE_LOG_MAX_BYTES` 控制(預設 100 MB) | -| `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數 | -| `network/url_validation.py` | `validate_url()`:scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | -| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`)、`truncate_for_display()`、`CONNECT_TIMEOUT` | -| `curl_import/` | `curl_parser.py`(401) 完整 curl 解析(短旗標叢集展開、`--data-urlencode`、`-F`、`-b`、`-G`);`request_body.py` 判斷 body 型別;`request_codegen.py` 產 requests 程式;`script_templates.py`(271) 產 APITestka/LoadDensity/pytest 模板 | -| `har_import/` | `har_parser.py`(304) HAR → `CurlRequest`(重用 curl 那套 codegen);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重 | +| `logging/logger.py` | `pybreeze_logger`(具名 logger,**不動 root logger**)+ `PyBreezeLogger(RotatingFileHandler)`:寫到 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改),UTF-8、附加模式、每行帶行程編號,第一筆紀錄才開檔;只在開檔時輪替,門檻 `PYBREEZE_LOG_MAX_BYTES`(預設 100 MB);開不了檔就改寫 `os.devnull` 並警告一次。與 JEditor、FrontEngine 同一套做法(工作區 X-6) | +| `exception/` | `ITEException` 為根的 17 個例外類別 + `exception_tags.py` 訊息常數;`error_templates.py` 把名稱以 `_error` 結尾的常數變成語言字典的 `error_text_<名稱>`(英文字典直接取常數本身) | +| `network/url_validation.py` | `validate_url()`:先拒絕 `urlparse` 與 `urllib3` 讀出不同主機的 URL(反斜線、空白、控制字元,或兩者主機不同;`_check_one_reading`),再做 scheme 白名單、私有/迴環/link-local/reserved 阻擋、額外處理 CGNAT 與 NAT64 網段、IPv6 內嵌 IPv4 的偵測 | +| `network/public_http.py` | 只連到剛檢查過的位址(防 DNS rebinding):`public_session()`(`_NoRedirectSession`:不跟也不準備轉址,3xx 原封不讀地回來;`PublicAddressAdapter`,連線開 socket 時把 urllib3 的 `_dns_host` 依序暫換成 `public_addresses()` 回傳的每個位址,連得上就用,全部失敗才丟最後一個錯誤)、`PublicHTTPHandler` / `PublicHTTPSHandler`(`http.client` 的 `_create_connection`)。主機名仍是連線的 host,所以 SNI、憑證檢查與 `Host` 標頭照舊;經 proxy 的連線不釘住。`overall_deadline(seconds)`:這個執行緒在區塊內的請求總共最多這麼久,釘住的連線等回應時把 socket 登記上去,時間到就 shutdown,丟 `ReadTimeout`(讀取逾時每來一個位元組就重算,慢慢送標頭的伺服器原本可以一直拖);AI 審查、Skill、CoT 每一步都包在 `overall_deadline(DEFAULT_MAX_READ_SECONDS)` 裡。`test_http_goes_through_public_connections.py` 擋下直接呼叫 `requests.*` / `urlopen` | +| `network/http_client.py` | `read_capped_text()`(串流讀取有上限,超出丟 `ResponseTooLargeError`;照 `Content-Type` 明寫的 charset 解碼,沒寫就 UTF-8,不用 requests 給 `text/*` 的 ISO-8859-1,`named_charset()`;整個回應最多讀 `DEFAULT_MAX_READ_SECONDS`(300 秒),時間到由 `_Watchdog` 關掉連線(urllib3 的 `HTTPResponse.shutdown()` 能中斷別的執行緒上正在等的讀取),丟 `ReadTimeout`:讀取逾時只管每一塊之間,一次送一個位元組的伺服器可以一直拖下去;狀態列與標頭由呼叫端的 `public_http.overall_deadline()` 涵蓋)、`describe_request_error()`(給使用者看的失敗原因:逾時、連不上、URL 不合法等,不含 URL;requests 的錯誤訊息會引用含 token 的完整 URL)、`succeeded()`(只有 2xx 算回答;`response.ok` 連 3xx 都算,這些請求又不跟隨轉址)、`truncate_for_display()`、`CONNECT_TIMEOUT` | +| `curl_import/` | `curl_parser.py`(600) 完整 curl 解析(`-I` 是 HEAD、`--oauth2-bearer` 變成 `Authorization`(`-H` 給的優先)、URL 的 `#fragment` 丟掉、URL 拆不開(沒關的 `[`、不是數字的 port)就是 `CurlParseException`,`url_is_well_formed()` 也給 HAR 用:這種 entry 跳過;同名的 `-F` 全留(`request_body.py` 的 `form_parts()` 回傳 list,產生的程式寫成 `files=[(…), …]`);表單一律以 multipart 送出:文字欄位也放進 `files=`,寫成 `(None, 文字)`(`form_entries()`),複製來的 `Content-Type: multipart/...` 不寫進 headers(`sent_headers()`,requests 要自己帶 boundary);APITestka JSON action 遇到上傳檔案、`@file` body 或 `-b` cookie 檔就丟 `CurlParseException`;`@file` body 一律以位元組讀(`--data-binary` 原樣、`-d` 去掉 CR/LF,和 curl 一樣),和其他 `-d` 片段照命令列的順序接起來(`data_file_positions`);`-b <檔案>` 存進 `cookie_files`,產生的程式以註解說明沒有讀它;`-G` 搭 `@file` 拒絕;bash 的 `$'...'` 先展開成一般引號字串再交給 `shlex`、短旗標叢集展開、`--data-urlencode`、`-F`、`--form-string`、`-b`、`-G`);`-F` 的值照 curl 語法(`@` 開頭是上傳檔案),`--form-string` 與 HAR 的文字欄位進 `form_strings`、照字面;URL 的 query 只有「解碼再編碼會一模一樣」時才拆進 `params`(`query_tools.query_round_trips()`,URL Builder 也用它決定 query 顯示成 dict 還是原字串;否則照原樣留在 URL,`requests` 原封送出,簽章 URL 才不會壞),query 參數 `params` 同一個 key 出現多次時存成值的清單(`add_repeated_value()`),URL 的在前、`-G` 的在後,和 curl 實際送出的一樣;`http_method()` 只收 RFC 9110 的 token(HAR 也用它),方法會寫進產生的程式碼,不是 token 就拒絕;`request_body.py` 判斷 body 型別(`body_kind()`:JSON 物件要能原樣送回才走 `json=`,重複的 key、float 裝不下的數字、`NaN`、巢狀超過 100 層都照原字串送);`request_codegen.py` 產 requests 程式(字串一律經 `python_string()`:`json.dumps` 預設把 BMP 以外的字元寫成兩個 surrogate,Python 讀成兩個字;JSON body 經 `python_literal()` 寫成 Python,`true`/`null` 在 Python 裡是未定義的名稱);`script_templates.py`(293) 產 APITestka/LoadDensity/pytest 模板 | +| `har_import/` | `har_parser.py`(354) HAR → `CurlRequest`(重用 curl 那套 codegen;建不出請求的 entry 跳過,含半個字元(JSON 的 `\ud800`)的也跳過,同名 cookie 改走 `Cookie` header);`is_api_like()` 濾掉靜態資源;`har_codegen.py` 批次產生單一腳本、函式名去重,每段開頭註解裡的控制字元寫成 `\xNN`(URL 裡的換行不會結束註解) | | `header_tools/` | `header_analyzer.py`(318) 安全稽核;`header_merge.py` 依 HTTP 規則合併重複 header(Cookie 用 `; ` 其餘用 `, `) | -| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯 | -| `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感)+ 資料夾選擇對話框包裝 | +| `jwt_tools/`、`hash_tools/`、`timestamp_tools/`、`regex_tools/`、`query_tools/`、`url_tools/`、`diff_tools/`、`http_reference/`、`json_format/`、`response_inspector/` | 對應 §6 工具分頁的純邏輯。`json_format` 的 Format / Minify 不改內容:數字保留原文(先換成帶隨機標記的佔位字串、輸出後一次換回)、非 ASCII 原樣輸出、同一物件重複的 key 與 `NaN`/`Infinity` 報錯;`pretty_json_or_none()` 是同一套解析、不記 log 的版本,Response Inspector 的 body 與 JWT 各段(`jwt_decoder.shown_json()`)用它排版。`json_format/view_safe.py` 的 `dumps_for_view()` / `escape_for_view()`:工具顯示的 JSON 把文字框還不回原樣的字元(U+2029、U+FDD0、U+FDD1 會變換行,落單的 surrogate 會消失;U+2028 經 `toPlainText()`、U+0085 經 `splitlines()` 也會斷行)寫成 `\uXXXX`,JSON Format、Query/URL 轉 JSON、JWT、Response Inspector、cURL/HAR 產生的 JSON 與 Python 字串都經過它。`query_tools` 與 `url_tools` 讀 JSON 也保留數字原文、拒收 `NaN` 與同一物件重複的 key(`load_json_verbatim()`,用 `json_process.unique_pairs`),Query 轉 JSON 遇到不是 UTF-8 的 percent-escape 報錯而不換成 U+FFFD,`urlencode` 經 `encode_pairs()`:寫不進 URL 的字元(落單的 surrogate)報自己的錯,不從分頁的 slot 漏出去 | +| `file_process/get_dir_file_list.py` | 遞迴收集指定副檔名的檔案(大小寫不敏感) | +| `file_process/read_capped.py` | `read_text_capped(path, encoding, max_bytes=None)`:先看大小,超過 `MAX_OPEN_BYTES`(100 MB)丟 `FileTooLargeError`(`OSError`,`strerror` 說明大小與上限),HAR 分頁與架構圖編輯器開檔都經它,不在 UI 執行緒讀好幾 GB 的檔案 | +| `file_process/replace_file.py` | `replace_text(path, text, private=False)`:寫到旁邊的 `<名稱>.saving` 再 `os.replace` 過去,失敗時原檔不動、半成品刪掉;`private` 以 `0600` 建立(存金鑰的檔案用);`replace_written(path, write)` 給別人寫的檔案(圖片、SVG):`write` 拿到旁邊的 `<主檔名>.saving<副檔名>`(副檔名不變,看副檔名決定格式的寫入器照樣用),寫完才換上 | | `manager/package_manager/` | `PackageManager`(單例 `package_manager`)持有 `syntax_check_list` | -**分層原則很一致**:`utils/` 幾乎不 import Qt(例外只有 `get_dir_file_list` 的 QFileDialog),所以 61 個單元測試全部跑得動。 +**分層原則**:`utils/` 不 import Qt 或 JEditor,由 `test_utils_has_no_qt.py` 守著,所以全部是不需要視窗的純邏輯測試。 --- ## 13. `pybreeze/extend_multi_language/` -`extend_english.py` 與 `extend_traditional_chinese.py` 各 571 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典。`test_language_parity.py` 守住兩邊鍵值必須對齊。 +`extend_english.py` 與 `extend_traditional_chinese.py` 各 708 個鍵,`update_language_dict()` 把它們併進 `je_editor` 的字典,並把 `application_name`(「PyBreeze」)寫進 `language_wrapper.choose_language_dict` 裡每一個語言:這是 PyBreeze 唯一覆寫而非新增的 JEditor 鍵,日文、簡中等 PyBreeze 沒翻譯的語言自帶「JEditor」,不寫的話會蓋過英文退回值。`test_language_parity.py` 守住兩邊鍵值必須對齊,也檢查每個已註冊語言都解得出程式用到的每個鍵;`test_startup_language.py` 在子行程裡用存好的繁中/日文真的啟動主視窗。 --- @@ -344,11 +370,13 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 純邏輯、無 Qt,值得單獨一節,因為它示範了本專案處理祕密的方式: -- 設定存 `~/.pybreeze/prthinker_setting.json` +- 設定存 `~/.pybreeze/prthinker_setting.json`(經 `replace_text(..., private=True)` 整檔替換,只有擁有者讀得到;讀取時什麼都不建立,存檔失敗時對話框會說;「額外參數」斷不了詞(引號沒關)就不存,`read_extra_arguments()` 與執行時用同一套斷詞) - `environment_for()` 把設定轉成 `PRTHINKER_*` 環境變數交給子行程,**命令列只留「這次要審什麼」** — API key 不會出現在工作管理員或執行紀錄 - `SECRET_SETTINGS` 四個欄位在 `loggable()` 中一律縮成 `(set)` / 空字串 -- `extra_arguments()` 用 `shlex.split()`,解析失敗當作沒有而不是讓整次審查失敗 -- `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上 +- 模型名稱依後端交給不同變數(`MODEL_ENVIRONMENT`):`remote` / `local` 是 `PRTHINKER_MODEL_NAME`,其他後端是各自的 `PRTHINKER__MODEL`。prthinker 只有 local 讀 `PRTHINKER_MODEL_NAME`(remote 由伺服器決定模型,只拿來標示) +- `extra_arguments()` 經 `split_arguments()` 以命令列規則斷詞(引號內空白不拆,`#` 不當註解);反斜線是路徑分隔字元的平台(Windows)上反斜線不當跳脫字元,`C:\reviews` 才不會變成 `C:reviews`。解析失敗當作沒有而不是讓整次審查失敗 +- `install_target()` 回傳 `[runner]`,因為 prthinker 不在 PyPI 上;資料夾要有 `pyproject.toml` 且專案名稱是 prthinker,否則不給目標、也不記住 +- 規則檢索(`rag`,`RAG_MODES = ("off", "remote")`)**永遠明講**,而且兩個變數都送:`off` 給 `PRTHINKER_RAG_ENABLED=false` + `PRTHINKER_REMOTE_RAG=false`,`remote` 兩個都 `true`(走伺服器的 `/rag`),認不得的值當 `off`。子行程繼承 IDE 的環境,少送一個就會被使用者 shell 裡的設定決定。prthinker 的預設是本機 FAISS 檢索,但那份索引(`codes/`)只在它的原始碼庫、被排除在套件外,從這裡裝的 prthinker 一跑就 `ModuleNotFoundError: codes` 支援的後端:`remote / local / openai / anthropic / gemini / cohere / mistral / claude-cli / codex-cli`;平台:`github / gitlab / gitea`。 @@ -358,7 +386,7 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | 模式 | 落點 | |---|---| -| **Facade** | `pybreeze/__init__.py` — 對外只暴露 `start_editor`、`PyBreezeMainWindow`、`EDITOR_EXTEND_TAB` 與轉出的插件 API | +| **Facade** | `pybreeze/__init__.py` — 對外只暴露 `start_editor`、`PyBreezeMainWindow`、`EDITOR_EXTEND_TAB` 與轉出的插件 API;第一次用到才 import(PEP 562 `__getattr__`),所以 `import pybreeze.utils.*` 不會連帶載入 PySide6 與 JEditor | | **Strategy** | 六個自動化模組共用 `build_process()`,差別只在 `_PACKAGE` | | **Template Method** | `TaskProcessManager` 固定 spawn → read threads → QTimer poll → drain → exit 的骨架 | | **Observer** | Queue + QTimer 把子行程輸出橋接到 UI 執行緒;Qt Signal/Slot(`SenderThread.update_response`、`JupyterLauncherThread.server_ready`) | @@ -375,20 +403,37 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 ``` UI Thread (QApplication) │ - ├── QTimer (100ms / 50ms) ──► pull_text() ──► pump_message_queue() ──► QTextEdit + ├── QTimer (100ms / 50ms) ──► pull_text() ──► pump_message_queue() ──► QPlainTextEdit │ ▲ │ thread-safe Queue │ ▲ - ├── daemon Thread: stdout readline ┤ - ├── daemon Thread: stderr readline ┘ + ├── daemon Thread: stdout read1 ───┤ + ├── daemon Thread: stderr read1 ───┘ + ├── daemon Thread: pybreeze-report-mail(send_after_test → send_report,結果經 _MailNotice queued signal 寫回執行視窗;_MailNotice 掛在 QApplication 底下、送達後 deleteLater,在 GUI 執行緒上刪除) │ ├── QThread: SSHReaderThread ──Signal──► terminal widget + ├── QThread: SshConnectThread ──Signal──► shell / file tree(連線+開 shell channel;host key 問題 BlockingQueued 回 UI) + ├── QThread: SftpListThread / SftpTransferThread / SftpCallThread ──Signal──► file tree ├── QThread: SenderThread (CoT) ──Signal──► review UI ├── QThread: RequestThread(Skills)──Signal──► result UI + ├── QThread: ReviewRequestThread ──Signal──► AI review client panel + ├── QThread: ImageDownloadThread ──Signal──► diagram canvas + ├── QThread: RegexMatchThread ──Signal──► Regex tab(pattern 在另一個行程跑) + ├── QThread: DiffThread ──Signal──► Diff tab └── QThread: JupyterLauncherThread──Signal──► QWebEngineView ``` -鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。 +垃圾回收只在 UI 執行緒跑:`start_editor()` 裝上 `gui_thread_gc.GuiThreadGarbageCollector`,關掉自動回收,改由 UI 執行緒上每秒一次的 QTimer 照直譯器自己的門檻回收(單元測試由 `test/test_utils/conftest.py` 做同樣的事)。自動回收會在任何配置超過門檻的執行緒跑,worker 上的一次回收曾把 UI 執行緒建立的 Qt 物件在 worker 上銷毀,計時器停不掉,之後 UI 執行緒把計時器事件送給已釋放的物件而崩潰。不要在 IDE 裡呼叫 `gc.enable()`。 + +給使用者看的文字:伺服器或檔案來的字(遠端路徑、錯誤訊息、主機名、檔名)放進 `QMessageBox` / `QLabel` 前經 `pybreeze_ui/plain_text.as_text()`(`Qt.convertFromPlainText`),Qt 才不會把它當 markup、去載入裡面的 ``;`test_message_boxes_show_text.py` 檢查每個 `QMessageBox`。 + +讀使用者輸入的文字一律走 `pybreeze_ui/exact_text.exact_text()`(`document().toRawText()`,區塊分隔換回 `\n`):`toPlainText()` 是顯示用的,會把不斷行空白(U+00A0)變成空白、U+2028 變成換行,Hash 算的是別的字串、Diff 看不出差別。 + +工具拒絕輸入的原因:`utils` 的純邏輯用 `exception_tags` 的英文常數拋例外、原樣寫進 log;工具分頁顯示前經 `pybreeze_ui/error_text.error_text()`,它認出訊息出自哪個常數(連同填進去的值與後面附加的細節),換成語言字典裡的 `error_text_<常數名>`。新增的 `_error` 常數要在繁中字典補翻譯,`test_error_text.py` 會檢查每一個。 + +鐵律:worker thread 一律不碰 UI。普通執行緒走 Queue + QTimer,`QThread` 走 Signal/Slot。分頁或視窗關閉時還在跑的 `QThread` 交給 `thread_keeper.let_run_out()`,不等它、也不讓它在執行中被銷毀。widget 留著的 thread(或其他物件)上接的 slot 不能抓住 widget 本身:接 bound method,或用 `thread_keeper.if_alive(weakref.ref(self), ...)`;lambda 抓 `self` 是經過 Qt 的循環參照,Python 的 GC 看不到,關掉的 widget 永遠不會釋放(`test_closed_panels_are_freed.py`)。 + +執行器的壽命:QTimer 接到執行器的 `pull_text()` / `_pull_text()` 這條連線**不會**讓執行器活著;reader 執行緒一結束,沒人持有的執行器就會被 GC,剩下的輸出和結束那一行都不會出現。所以執行器一律掛在它寫入的 `CodeWindow.runner` 上,跟視窗同壽;視窗又掛在主視窗的 `current_run_code_window`。 --- @@ -404,13 +449,13 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 | `response_stats.txt` | AI 審查接受/拒絕統計 | | `urls.txt` | AI 審查端點歷史 | -另有工作目錄下的 `PyBreeze.log`(rotating,預設 100 MB)與各自動化套件自己的 log。 +另有 `~/.pybreeze/logs/PyBreeze.log`(`PYBREEZE_LOG_FILE` 可改;開檔時超過 100 MB 就輪替成 `.1`)與各自動化套件自己的 log。 --- ## 18. 測試與 CI -- **單元測試** `test/test_utils/` — 65 個 `test_*.py`、985 個測試。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 +- **單元測試** `test/test_utils/` — 124 個 `test_*.py`、2251 個測試(14 個 prthinker 契約測試在沒有 prthinker 的直譯器上跳過)。純邏輯 + headless Qt widget 測試(`QT_QPA_PLATFORM=offscreen`)。涵蓋 curl/HAR 解析、SSRF 驗證、SSH 安全、process reader EOF、queue pump、語言對齊、mermaid parser、diagram 序列化、prthinker 設定、JEditor 內部介面契約(`test_jeditor_contract.py`)、`except Exception` 只能重拋或註明理由(`test_no_blind_except.py`)等。有 hypothesis fuzz 測試(`test_fuzz_pure_logic.py`)。 - **整合測試** `test/unit_test/start_automation/` — 以 `debug_mode=True` 啟動 IDE,10 秒後自動關閉,驗證啟動流程與 extend tab - **CI** `.github/workflows/{dev,stable}.yml` — `unit-tests` job 跑 Windows runner、Python 3.10–3.14 矩陣,3.12 那一腳額外上傳 `coverage-xml` artifact;`sonarcloud` job 跑 ubuntu、`needs: unit-tests`。每日 02:00 排程 + push/PR 觸發。`stable.yml` 另有 `publish` job 負責版號遞增與 PyPI 發布 - **覆蓋率** `.coveragerc` — `relative_files = True` 是必要的:報告在 Windows 產生、由 Linux 上的 scanner 讀取,路徑不能帶機器資訊。目前整體 60%(`utils/`、`tools_gui`、`dialog` 95–100%;`editor_main` 58%、`menu` 54%;仍低的是 `diagram_editor` 45%、`process_executor` 39%、`connect_gui` 28%) @@ -423,23 +468,13 @@ first_summary → first_code_review → judge_single_review ┐(評分前一 以下是客觀觀察,不是缺陷判定,但值得留意: -1. **三份幾乎相同的 pump/drain 邏輯** — `TaskProcessManager`、`TestPioneerProcess`、`FileRunnerProcess` 各自實作 `_read_stream_into_queue` / `drain_*` / `_append_text`。`queue_pump.py` 只抽走了其中兩者的 pump 部分;`_append_text` 三處逐字重複(CLAUDE.md 的「6 行以上重複區塊應抽 helper」規則)。 - -2. **執行視窗會吃掉輸出的縮排** — `FileRunnerProcess._pull_text()` / `_drain_queues()` 與 `queue_pump.pump_message_queue()` 都對每一行做 `.strip()`。對日誌型輸出無感,但子行程若輸出程式碼或任何有縮排結構的文字,執行視窗裡會全部靠左(`images/run_output_window.png` 這張實拍截圖就看得到)。修法是只去掉行尾換行。 - -3. **`file_tree_context_menu.setup_file_tree_context_menu()` 用 monkey patch** — 直接覆寫 `main_window.tab_widget.addTab` 來攔截新分頁。可行但脆弱,若他處也包裝 `addTab` 會疊加。 - -4. **`PyBreezeMainWindow.__init__` 的 `extend` 參數語意分歧** — 對 `super()` 恆傳 `extend=True`,而參數本身只用來決定要不要設定 Windows AppUserModelID 與視窗圖示。 - -5. **`start_editor()` 以 `os._exit(ret)` 收場** — 繞過 atexit 與 Qt 拆解。對 GUI 主程式常見(避免殘留執行緒卡住),但 `closeEvent` 之後的清理路徑等於不存在。 - -6. **`mail_thunder_setting.send_after_test()` 捕捉裸 `Exception`** — 最後一個 `except Exception` 只記 log,符合「不讓寄信失敗炸掉測試」的意圖,但與 CLAUDE.md「避免捕捉 Exception 除非立即重拋」有張力。 +1. **`file_tree_context_menu.setup_file_tree_context_menu()` 用 monkey patch** — 直接覆寫 `main_window.tab_widget.addTab` 來攔截新分頁。可行但脆弱,若他處也包裝 `addTab` 會疊加。 -7. **`test_pioneer_process_manager` 建構子直接呼叫 `check_and_choose_venv()`** — 不像 `TaskProcessManager.renew_path()` 那樣把找不到解譯器的情況轉成視窗訊息,這裡會直接讓 `JEditorExecException` 從選單 callback 逃出去。 +2. **`PyBreezeMainWindow.__init__` 的 `extend` 參數語意分歧** — 對 `super()` 恆傳 `extend=True`,而參數本身只用來決定要不要設定 Windows AppUserModelID 與視窗圖示。 -8. **`PackageManager` 名實不符** — 類別名暗示 pip 管理,實際只承載 `syntax_check_list` 六個項目;pip 安裝落在 `install_utils.install_package()`。 +3. **`start_editor()` 以 `os._exit(ret)` 收場** — 繞過 atexit 與 Qt 拆解。對 GUI 主程式常見(避免殘留執行緒卡住),但 `closeEvent` 之後的清理路徑等於不存在。 -9. **`PLUGIN_GUIDE.md` 指向不存在的目錄** — 文件末尾說內建插件都在 `exe/jeditor_plugins/`,但該目錄不在 repo 裡,`je_editor` 套件內也只有 `plugin_loader.py`。README 已改為以「範例」描述這些插件。 +4. **`PackageManager` 名實不符** — 類別名暗示 pip 管理,實際只承載 `syntax_check_list` 六個項目;pip 安裝落在 `install_utils.install_package()`。 --- diff --git a/dev.toml b/dev.toml index e6618761..b6d349fb 100644 --- a/dev.toml +++ b/dev.toml @@ -14,9 +14,9 @@ description = "IDE for multi automation" requires-python = ">=3.10" license-files = ["LICENSE"] dependencies = [ - "je-editor", "je_auto_control", "je_web_runner", + "je-editor>=1.0.27", "je_auto_control", "je_web_runner", "je_load_density", "je_api_testka", "je-mail-thunder", - "automation-file", "PySide6==6.11.0", "test_pioneer", "paramiko", + "automation-file", "PySide6==6.11.2", "test_pioneer", "paramiko", "jupyterlab", ] classifiers = [ @@ -33,8 +33,8 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/Intergration-Automation-Testing/AutomationEditor" -Code = "https://github.com/Intergration-Automation-Testing/AutomationEditor" +Homepage = "https://github.com/Integration-Automation/PyBreeze" +Code = "https://github.com/Integration-Automation/PyBreeze" [project.readme] file = "README.md" diff --git a/dev_requirements.txt b/dev_requirements.txt index 2c38764e..57fd9b1d 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,14 +1,18 @@ -build -twine -PySide6 -je-api-testka -je-load-density -je-auto-control -je-web-runner -je-mail-thunder -pybreeze_dev -sphinx -sphinx-rtd-theme -PySide6 -auto-py-to-exe -test_pioneer \ No newline at end of file +# -*- coding: utf-8 -*- +# 開發用:執行所需的套件(requirements.txt)加上測試、lint、文件與打包工具。 +# 這裡不列 pybreeze_dev:那會把 PyPI 上發佈的版本裝進來,CI 測到的就不是工作目錄的程式碼。 +# +# For development: everything PyBreeze needs to run (requirements.txt) plus the +# test, lint, docs and packaging tools. It does not list pybreeze_dev: that would +# install the published build, so CI would be testing PyPI's code instead of the +# working tree. +-r requirements.txt +pytest +pytest-cov +hypothesis +ruff +sphinx +sphinx-rtd-theme +build +twine +auto-py-to-exe diff --git a/docs/source/Eng/ai_tools.rst b/docs/source/Eng/ai_tools.rst index ee888944..7eb26c1a 100644 --- a/docs/source/Eng/ai_tools.rst +++ b/docs/source/Eng/ai_tools.rst @@ -25,7 +25,8 @@ Features ^^^^^^^^ - Tracks accept/reject statistics for AI responses -- Saves URL history to ``.pybreeze/urls.txt`` +- Records which endpoints have been used in ``.pybreeze/urls.txt``, as fingerprints: + an API URL can carry a token, so the URL itself is never written to disk - Stores response statistics in ``.pybreeze/response_stats.txt`` Usage diff --git a/docs/source/Eng/menu_install.rst b/docs/source/Eng/menu_install.rst index fb375075..c1d141dc 100644 --- a/docs/source/Eng/menu_install.rst +++ b/docs/source/Eng/menu_install.rst @@ -27,6 +27,9 @@ Installs the automation module packages via pip. - Runs ``pip install -U automation_file`` * - **Install MailThunder** - Runs ``pip install -U je_mail_thunder`` + * - **Install prthinker (code review)** + - prthinker is not on PyPI. The first time, asks for its source folder and + remembers it; then runs ``pip install -U [runner]`` Tools Submenu ------------- @@ -43,6 +46,8 @@ Tools Submenu .. note:: - All installation commands run in the shell runner and respect the currently - active virtual environment. If a ``.venv`` or ``venv`` directory is detected, - packages will be installed into that environment. + Each install opens a run window of its own and runs ``python -m pip`` there, + with no shell in between, so a folder name holding ``&`` or ``|`` reaches pip + unchanged. pip runs with the interpreter chosen in the Python environment + menu; when none is chosen, with a ``venv`` or ``.venv`` in the working + directory, and otherwise with the Python found on ``PATH``. diff --git a/docs/source/Eng/menu_plugins.rst b/docs/source/Eng/menu_plugins.rst index 52ec4532..f08a7445 100644 --- a/docs/source/Eng/menu_plugins.rst +++ b/docs/source/Eng/menu_plugins.rst @@ -44,7 +44,8 @@ Creating Plugins For detailed information on creating custom plugins, including syntax highlighting plugins and UI translation plugins, see the -`Plugin Guide `_. +`Plugin Guide `_ +(the guide lives in the JEditor repository, since PyBreeze uses JEditor's plugin system). Syntax Highlighting Plugin Example ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/Zh/ai_tools.rst b/docs/source/Zh/ai_tools.rst index 62f90350..81b6d346 100644 --- a/docs/source/Zh/ai_tools.rst +++ b/docs/source/Zh/ai_tools.rst @@ -24,7 +24,8 @@ AI 程式碼審查用戶端 ^^^^ - 追蹤 AI 回應的接受/拒絕統計 -- 將 URL 歷史儲存到 ``.pybreeze/urls.txt`` +- 在 ``.pybreeze/urls.txt`` 記錄用過哪些端點,存的是指紋:API URL 可能帶著權杖, + 所以不會把 URL 本身寫進磁碟 - 將回應統計儲存到 ``.pybreeze/response_stats.txt`` 使用方式 diff --git a/docs/source/Zh/menu_install.rst b/docs/source/Zh/menu_install.rst index b8045702..45d70526 100644 --- a/docs/source/Zh/menu_install.rst +++ b/docs/source/Zh/menu_install.rst @@ -27,6 +27,9 @@ Automation 子選單 - 執行 ``pip install -U automation_file`` * - **Install MailThunder** - 執行 ``pip install -U je_mail_thunder`` + * - **Install prthinker (code review)** + - prthinker 不在 PyPI 上。第一次會詢問它的原始碼資料夾並記下來, + 之後執行 ``pip install -U <資料夾>[runner]`` Tools 子選單 ------------ @@ -43,5 +46,6 @@ Tools 子選單 .. note:: - 所有安裝命令都在 Shell 運行器中執行,並遵循目前啟用的虛擬環境。 - 如果偵測到 ``.venv`` 或 ``venv`` 目錄,套件將安裝到該環境中。 + 每次安裝都會開一個自己的執行視窗,在裡面執行 ``python -m pip``,中間不經過 shell, + 所以資料夾名稱裡有 ``&`` 或 ``|`` 也會原樣交給 pip。pip 使用 Python 環境選單選定的 + 直譯器;沒有選的話,使用工作目錄下的 ``venv`` 或 ``.venv``,再沒有就用 ``PATH`` 上找到的 Python。 diff --git a/docs/source/Zh/menu_plugins.rst b/docs/source/Zh/menu_plugins.rst index 5f214d6a..8b6d4531 100644 --- a/docs/source/Zh/menu_plugins.rst +++ b/docs/source/Zh/menu_plugins.rst @@ -42,7 +42,8 @@ Run With 選單 -------- 關於建立自訂外掛的詳細資訊,包括語法高亮外掛和 UI 翻譯外掛, -請參閱 `外掛指南 `_。 +請參閱 `外掛指南 `_ +(指南放在 JEditor repo,因為 PyBreeze 用的是 JEditor 的插件系統)。 語法高亮外掛範例 ^^^^^^^^^^^^^^^^ diff --git a/docs/updates/2026-09-b.md b/docs/updates/2026-09-b.md new file mode 100644 index 00000000..66e6d9f7 --- /dev/null +++ b/docs/updates/2026-09-b.md @@ -0,0 +1,642 @@ +# 2026-09 update log, continued + +Continues [2026-09.md](2026-09.md), which passed the batch size limit. Index and query commands: [README.md](README.md). New entries go at the end. + +--- + +## U-20260923-201 · 2026-09-24 · Continue the September update log in 2026-09-b.md · #docs + +- **What**: batch rule 2 (`docs/updates/README.md`) says a month's file continues in `YYYY-MM-b.md` past about 800 lines; `2026-09.md` had reached 3156 lines and 218 entries without it. New entries now go to `2026-09-b.md`, listed in the Batches table. Recorded entries stay where they are (rule 5), and their index rows keep pointing at `2026-09.md`; its header now says where it continues. +- **Files**: `docs/updates/2026-09-b.md` (new), `docs/updates/2026-09.md` (header), `docs/updates/README.md` (Batches, index) +- **Open items**: none. + +## U-20260923-202 · 2026-09-24 · Bring the architecture map, README and CLAUDE.md back in line with the code · #docs + +- **What**: an audit of every backticked name, quoted count and stated behaviour in the documentation against the code (`architecture.md` and `PLUGIN_GUIDE.md` were accurate). + - `architecture_explore.md`: the package is 201 `.py` files and about 22,200 lines (said 189 and 16,300); 643 translation keys (said 571, three places); `start_editor()` at `main_ui.py:189` (said 100); 13 tool widgets (said 19 tool tabs); 18 `utils` subpackages (said 14, twice); 15 menu builders (said 14); `ssh/` 2,035 lines and the diagram editor 3,963 (said 1,740 and 3,760); two quoted file lengths refreshed; `add_image()` is `DiagramScene`'s, and `form_parts()` is in `request_body.py`. Added what the map left out: `plain_text.as_text()` (with the message-box check), `menu_utils.open_web_browser()`, `extend_jeditor_tab_menu/jupyter_lab_tab.py`, `mail_thunder_setting.py`. + - `README.md`: 643 keys; only batch and file runs pass the action file by path (running the tab in front passes its script on the command line, so the argv limit can apply there); Package Manager output goes to a run window, not the shell pane; a plugin gets one run action labelled with its suffixes, and `PLUGIN_GUIDE.md` links JEditor's guide for the full API; `pytest-cov` and `ruff` added to the development tools. + - `CLAUDE.md`: `apply_host_key_policy(client, parent)`; the tree lists `__main__.py`, `menu_utils` and `extend_jeditor_tab_menu`, and says docks are built in `tools`. +- **Files**: `architecture_explore.md`, `README.md`, `CLAUDE.md` +- **Open items**: none. + +## U-20260923-203 · 2026-09-24 · Run a script too long for a Windows command line from a file · #fix #executor + +- **What**: running the tab in front passes its script to the automation package as `--execute_str` on the command line. Windows refuses a command line over 32,767 characters, so an action script past that (about 600 steps) did not start at all: the run window said only "could not start". Batch and file runs already passed a path. +- **Fix**: `TaskProcessManager.start_test_process` measures the command line (`subprocess.list2cmdline`) and, on Windows past 30,000 characters, writes the script to a temporary `pybreeze_run_*.json` and passes it with `--execute_file`, the legacy flag every package takes. The JSON is written back with every character escaped: the released APITestka and MailThunder read that file in the locale's encoding (both have since fixed it in their sources), and an escape reads the same in any. The file is removed when the run ends or fails to start. Shorter scripts go on the command line as before. +- **Tests**: new `test_long_scripts.py` (2), through a stand-in package that reports the flag and what it read in ASCII: a 600-step script with Chinese text goes as a file, arrives whole, and the file is gone after the run (Windows only); a short script still goes as `--execute_str`. +- **Result / numbers**: the long run itself takes 0.4 s. 2017 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `test/test_utils/test_long_scripts.py` (new), `README.md`, `architecture_explore.md` §4, §18 +- **Open items**: none. + +## U-20260923-204 · 2026-09-24 · Keep every recorded call, cookie and entry when importing a HAR file · #fix #har + +- **What**: three ways the HAR importer lost part of a recording. + - Test names: repeats were numbered `_2`, `_3` without looking at the other requests' own names, so GET `/a`, `/a`, `/a/2` became `test_get_a`, `test_get_a_2`, `test_get_a_2` and the second call to `/a` was never tested, the very thing the numbering was there to stop. + - Cookies: a browser sends two cookies of one name when their paths differ. They went into a dictionary, which kept the last, and the `Cookie` header that still had both was removed, so `sid=1` was gone from the generated request. + - Methods: one entry with a method that is not a token (`""`) refused the whole file, while an entry with a malformed URL is skipped. +- **Fix**: `unique_test_names` skips a number another request's own name has. `_apply_cookies` keeps the structured list only when its names are unique; otherwise the cookies go as the `Cookie` header, the recorded one or one built in the recorded order. `parse_har` skips an entry whose request cannot be built and logs why; a file left with none raises the first entry's reason, so a single bad method still reads "not an HTTP method". +- **Tests**: `test_har_import.py` +6: three orders of `/a`, `/a/2` give distinct names; two `sid` cookies with and without the header; a bad-method entry beside a good one. +- **Result / numbers**: 2023 tests in 121 files. The full suite before this change: 2002 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/utils/har_import/har_codegen.py`, `pybreeze/utils/har_import/har_parser.py`, `test/test_utils/test_har_import.py`, `README.md`, `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-205 · 2026-09-24 · Move exact_text beside plain_text in pybreeze_ui · #refactor + +- **What**: a refactor, no behaviour change. `exact_text()` (a text box read as typed, through `toRawText()`) was in `tools_gui/`, but the prompt editor, the AI panels and the diagram editor's Mermaid import read their text boxes too and are about to use it. It moves to `pybreeze_ui/exact_text.py`, beside `plain_text.py`, the other helper shared across the UI. +- **Tests**: `test_exact_text.py`, `test_view_safe.py` and `test_closed_panels_are_freed.py` pass (40). `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/exact_text.py` (moved from `tools_gui/`), eleven `tools_gui` modules' imports, `test/test_utils/test_exact_text.py`, `test/test_utils/test_view_safe.py`, `CLAUDE.md` (tree), `architecture_explore.md` §6, §16 +- **Open items**: none. + +## U-20260923-206 · 2026-09-24 · Save a prompt file with the characters it was opened with · #fix #ai + +- **What**: the prompt editor saved `toPlainText()`, which is for display: a no-break space came back as a space and U+2028 as a newline. Opening a prompt file and saving it, even after a one-character edit, changed characters the user never touched, and `replace_text` wrote that over their file. The AI panels (CoT review, Skills, AI code review) and the Mermaid import read their text boxes the same way, so what was sent or converted was not what was pasted. +- **Fix**: the five read through `exact_text()` (`document().toRawText()`, block breaks back to `\n`), as the tool tabs already did. U+2029, U+FDD0 and U+FDD1 in a file still come back as line breaks: no Qt text box keeps them apart from one. +- **Tests**: `test_prompt_store.py` +1: a prompt with a no-break space and U+2028, opened and saved, is on disk byte for byte as before. +- **Result / numbers**: 2024 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py`, `extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/cot_code_review_gui.py`, `connect_gui/url/ai_code_review_gui.py`, `diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_prompt_store.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-207 · 2026-09-24 · Round a decimal epoch toward the past and refuse an offset past 59 minutes · #fix #timestamp + +- **What**: two ways the Timestamp Converter answered with an instant it was not given. + - A decimal epoch went through `float` and `timedelta(seconds=...)`, which rounds to the nearest microsecond: `0.0000009` came out a microsecond after the epoch, and `1700000000.9999999` a microsecond before the next second rounded up, while the result's fields say rounded toward the past and a whole number and an ISO fraction are cut. + - A zone offset's minutes were not checked: `2024-01-01T00:00+05:99` was taken as +06:39. +- **Fix**: a decimal epoch is read as a `Decimal` and cut to the microsecond (`_decimal_microseconds`, 60 digits, the widest exponent range, so `-1e-999999999` is a microsecond before 1970, not 0). One that is infinite, NaN or has 30 or more digits before the point is refused before any arithmetic (`1e999999999` overflowed the decimal context). `_zone` refuses 60 minutes or more. +- **Tests**: `test_timestamp_converter.py` +13: six decimals cut to the microsecond, six inputs no date can be refused, `+05:99` refused and `+05:59` taken. Passes on 3.11 and 3.10. +- **Result / numbers**: 2037 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-208 · 2026-09-24 · Keep the prthinker extra arguments after a # · #fix #prthinker + +- **What**: `split_arguments` built its own `shlex.shlex`, which reads `#` as the start of a comment (`shlex.split` turns that off; a hand-built lexer does not). Everything from the first `#` was dropped without a word: `--language C# --verbose` gave `--language C`, `--rules-url https://x/y#sec --max 3` lost the fragment and `--max 3`, and `--focus #security --depth 2` kept only `--focus`. The settings dialog checks with the same function, so it said nothing either. +- **Fix**: `lexer.commenters = ""`. +- **Tests**: `test_prthinker_setting.py` +2: `C#`, a URL with a fragment and `#security` all stay whole, with and without backslash escapes. +- **Result / numbers**: 2039 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §14, §18 +- **Open items**: none. + +## U-20260923-209 · 2026-09-24 · Skip a diagram image entry that is not an object instead of failing Open · #fix #diagram + +- **What**: a `.diagram.json` whose `images` list held something other than an object (`"x"`, `5`) made `DiagramImage.from_dict` call `.get` on it. The `AttributeError` was not among what `_load_images` skips, `load_from_dict` put the old canvas back and raised it again, and `_open_diagram` does not catch it either, so it escaped the slot and Open said nothing. A node or connection entry of that kind was already skipped. +- **Fix**: `_load_images` also skips on `AttributeError`, as `_load_connections` does. +- **Tests**: `test_diagram_serialization.py` +4: `"x"`, `5`, `null` and a list as a node, connection and image entry are skipped, and the good node still loads. +- **Result / numbers**: 2043 tests in 121 files. The full suite before this change: 2022 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-210 · 2026-09-24 · Keep a quoted Mermaid edge label that holds a bar · #fix #diagram + +- **What**: Mermaid takes a quoted edge label between bars, `A-->|"a|b"|B`. The line was split with the quotes kept together, but `_parse_arrow` read the label with `\|([^|]*)\|`, which stopped at the bar inside the quotes: the edge was labelled `"a`. +- **Fix**: the label pattern takes a quoted label whole before an unquoted one (`\|\s*("[^"]*"|[^|]*)\s*\|`). +- **Tests**: `test_mermaid_parser.py` +2: `|"a|b"|` and `| "a|b" |` give the label `a|b` between two nodes. +- **Result / numbers**: 2045 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `test/test_utils/test_mermaid_parser.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-211 · 2026-09-24 · Keep an empty host empty when the URL Builder rebuilds a URL · #fix #url + +- **What**: `http:////x` has an empty host and the path `//x`. `build_url` handed those to `urlunsplit`, which writes no empty authority, so the result was `http://x`: the path's first segment had become the host, and URL to JSON and back pointed somewhere else. +- **Fix**: with no authority and a path starting `//`, `build_url` writes the empty authority itself (`scheme:` + `//` + path, then the query and fragment as `urlunsplit` writes them). +- **Tests**: `test_url_convert.py` +4: `http:////x`, one with a query and fragment, `////x` and `file:////srv/share` rebuild as they were. Passes on 3.11 and 3.10. +- **Result / numbers**: 2049 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-212 · 2026-09-24 · Read an HTTP/2 status pseudo-header only when it is ASCII digits · #fix #inspector + +- **What**: the Response Inspector took a `:status:` pseudo-header's value when `str.isdigit()` held, which it does for `²`; `int("²")` then raised `ValueError`, and `response_inspector_gui` does not catch it, so pasting `:status: ²` raised out of the tab. +- **Fix**: the value must also be ASCII (`isascii()` and `isdigit()`), as the URL Builder's port already is. +- **Tests**: `test_response_analyzer.py` +3: `²`, Arabic-Indic digits and `4 0 4` give no status, and the headers after it are still read. +- **Result / numbers**: 2052 tests in 121 files. The full suite before this change: 2034 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-213 · 2026-09-24 · Show one changed line in a long repetitive text as one line · #fix #diff + +- **What**: `difflib.SequenceMatcher` treats a line making up more than 1% of a sequence of 200 or more as junk that cannot anchor a match. In a JSON or code response full of `}`, one changed line came out as hundreds removed and added: 300 lines of `{`, ` a`, `}` with one `a` changed gave 299 and 299, and 300 lines of `x` with the first changed gave 300 and 300. Turning the heuristic off is not the answer on its own: without it, 20,000 alike lines took 62 s to match. +- **Fix**: `_TrimmedMatcher` sets the unchanged head and tail aside and matches only what is left, with the heuristic off below 2,000 lines left (`_AUTOJUNK_FROM`); its `get_opcodes` gives the head and tail back as equal blocks, so the grouped opcodes, the unified diff and the counts come from it as before. The examples above are now 1 and 1, and the 20,000 lines take 8 ms. +- **Tests**: `test_text_diff.py`: the property that the diff is exactly `difflib.unified_diff`'s is replaced, since where two alignments are as short the trimmed match may pick the other: now the diff applied to the left text gives the right text line for line, the counts are its `+` and `-` lines, and it is never longer than `difflib`'s (300 examples). +2: the two examples above. +- **Result / numbers**: 2054 tests in 121 files. `ruff check` clean, complexity within 15. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-214 · 2026-09-24 · Share one refuse_constant between the JSON tools · #refactor + +- **What**: a refactor, no behaviour change. `json_process` and `query_convert` each had the same `_refuse_constant` (the `parse_constant` hook that refuses `NaN` and `Infinity`), and the cURL body check is about to need it too. It is now `json_process.refuse_constant`, imported by `query_convert`. +- **Tests**: the JSON, query, URL and fuzz tests pass (515). `ruff check` clean. +- **Files**: `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/query_tools/query_convert.py` +- **Open items**: none. + +## U-20260923-215 · 2026-09-24 · Send a JSON body raw when the object would not send it back as it was · #fix #curl + +- **What**: `body_kind` turned a body sent with `Content-Type: application/json` into an object with a plain `json.loads`, and the generated scripts send that object (`json=`). What they sent was not what curl sends: + - a repeated key kept only its last value (`{"a":1,"a":2}` became `{"a": 2}`); + - a number a float cannot hold was rounded (`0.10000000000000000001` became `0.1`, `1e400` became `inf`), and `NaN` was taken; + - JSON nested deeper than the parser goes raised `RecursionError`, which the cURL tab (it catches `CurlParseException`), the HAR generator and the script templates let escape; + - nested past about 600 levels, writing the object as a Python literal recursed past the limit in three generators, and past 200 Python refuses the script anyway. +- **Fix**: the body is parsed with `unique_pairs`, `refuse_constant` and `_exact_float` (a float whose `repr` is not the same number as the text is refused), and taken as JSON only when it nests 100 levels or fewer (`_MAX_LITERAL_DEPTH`, counted without recursing); otherwise it is sent raw, as curl sends it. `0.1`, `1E3` and a long integer still go as JSON. +- **Tests**: `test_script_templates.py` +7: a repeated key, a long float, `1e400`, `NaN`, 5,000 levels and 102 levels go raw, every template target generates, and the `requests` script compiles; `0.1`, `1E3` and a 20-digit integer still go as JSON. +- **Result / numbers**: 2061 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/request_body.py`, `test/test_utils/test_script_templates.py`, `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-216 · 2026-09-24 · Send curl's data pieces in command-line order when one is a file · #fix #curl + +- **What**: the parser kept `-d` pieces in two lists, inline text in `data_parts` and `@file` names in `data_file_refs`, and `data_from_file_expr` wrote every inline piece first. curl joins the pieces in the order given, so `curl -d @a.txt -d b=1` sends the file then `b=1`, while the generated script sent `b=1` then the file. +- **Fix**: the parser also records, for each file, how many inline pieces came before it (`CurlRequest.data_file_positions`); `data_from_file_expr` walks the pieces in that order (`_data_pieces`), joining neighbouring inline pieces into one string as before. A request built without positions puts its files after the inline pieces, as it did. +- **Tests**: `test_curl_import.py` +3: the generated expression, evaluated against real files, gives `A&b=1`, `b=1&A&c=2&Z\r\n` (a `--data-binary` file kept byte for byte, a `-d` file without CR/LF) and `x=1&y=2&A`. +- **Result / numbers**: 2064 tests in 121 files. `ruff check` clean, complexity within 15. +- **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/curl_import/request_codegen.py`, `test/test_utils/test_curl_import.py`, `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-217 · 2026-09-24 · Say when an SSH key's passphrase is missing or wrong · #fix #ssh + +- **What**: `load_private_key` tries each key class and gives `None` when none loads, and both the shell tab and the SFTP tree then said "Unsupported or invalid private key." That was also what an encrypted key with a wrong or empty passphrase gave (paramiko raises `PasswordRequiredException` or "checkints do not match" / "Bad password" there), so the user was sent looking for a key problem that was a typing one. +- **Fix**: `unloadable_key_reason(key_path, password)` asks each key class again with no passphrase; one that asks for a passphrase means the file is encrypted, so the reason is the passphrase: missing when none was given, wrong otherwise. Anything else stays unsupported. It is looked at only after a key failed to load, and reads no message text. Two new language keys, `ssh_key_error_passphrase_needed` and `ssh_key_error_passphrase_wrong` (645 keys each). +- **Tests**: `test_ssh_security.py` +6: encrypted RSA and Ed25519 keys in OpenSSH format and RSA and ECDSA keys in PEM load with the right passphrase and are reported as wrong / needed passphrase otherwise; a file that is not a key, and a missing file, are unsupported; each reason has a message in both languages. +- **Result / numbers**: 2070 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `sftp_session.py`, `ssh_command_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py`, `test/test_utils/test_ssh_security.py`, `README.md`, `architecture_explore.md` §2, §3, §9, §13, §18 +- **Open items**: none. + +## U-20260923-218 · 2026-09-24 · Skip a bad known_hosts line instead of failing the SSH connect · #fix #ssh + +- **What**: paramiko's `HostKeys.load` skips a line that raises `SSHException`, but a line whose key is not base64 raises `InvalidHostKey`, which is no `SSHException`, and loading stops there. One such line in `~/.ssh/known_hosts` or `~/.pybreeze/ssh_known_hosts` (a truncated paste, a hand edit) made `apply_host_key_policy` raise: out of the shell tab's Connect slot, and in the SFTP tree before `SshConnectThread.run`'s `try`, so the connect ended with no message. The same line in the middle of a connect made the host key question raise. And accepting a host rewrote the file from what paramiko could read, so a bad line or an `ssh-dss` key (paramiko 4 dropped DSA) vanished from it. +- **Fix**: `load_known_hosts(target, path)` reads the file line by line as `HostKeys.load` does, skipping a line that cannot be read with a warning (its line number, not its content), as OpenSSH does; `apply_host_key_policy` loads both files through it into the client's keys, and `_read_known_hosts` uses it too. `_store` appends one line for the accepted key to the file as it is, still replaced in one step, instead of writing back what paramiko parsed. +- **Tests**: `test_ssh_host_key_policy.py` +2: a bad line before a good one leaves the good host known and the Connect set up; accepting a host keeps a bad line and an `ssh-dss` line where they were. +- **Result / numbers**: 2072 tests in 121 files. The full suite before the passphrase change: 2049 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-219 · 2026-09-24 · Keep a carriage return split from what follows it in the run window and the SSH terminal · #fix #output + +- **What**: output arrives in reads that end wherever a buffer did. + - Run window: a read ending on `\r\x1b[` (a progress bar's rewind-and-clear, `\r\x1b[K`) sent the `\r` with the cut escape held back, and once the escape was stripped the piece ended on a lone `\r`, which the window drops as waiting for what comes next. The rewind never happened: `50%\r\x1b[K60%\n` read in two showed `50%60%`. Holding the `\r` with the escape was not enough on its own: the piece `\r\x1b[K` still ends on a bare `\r` once stripped. + - SSH terminal: `TerminalDecoder` did not hold a trailing `\r` at all, and the view takes a lone `\r` as a paragraph break, so a `\r\n` split between two 4 KB reads made a blank line in long output. +- **Fix**: `terminal_text.split_unfinished_end()` holds the unfinished escape and a `\r` just before it or at the very end; `queue_pump.read_stream_into_queue` and `TerminalDecoder.feed` both use it. `CodeWindow` remembers a piece that ended on a lone `\r` (`_rewind_pending`) and applies it to the next: a line break before `\n`, a rewind before anything else, so a finished bar still stays shown. +- **Tests**: `test_code_window.py` +15: `50%\r\x1b[K60%\n` read with every buffer size from 1 to 15 shows `60%`; `test_ssh_terminal_output.py` +1: `line1\r` then `\nline2\r\n` is two lines, no blank one. +- **Result / numbers**: 2088 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/terminal_text.py`, `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` §4, §9, §12, §18 +- **Open items**: a backspace at the start of a read cannot take back a character shown by the previous read (a spinner writing `|`, `\b/`, `\b-` as separate reads shows `|/-`): progress #105. + +## U-20260923-220 · 2026-09-24 · Let a backspace take back what an earlier read showed · #fix #output #done + +- **What**: progress #105. `strip_terminal_controls` applies a backspace to the character before it in the same piece and drops the rest, so one at the start of a read, meant for a character the previous read already put on screen, did nothing: a spinner writing `|`, `\b/`, `\b-` in separate writes showed `|/-` in the run window and the SSH terminal. +- **Fix**: `terminal_text.take_leading_backspaces()` splits off the backspaces a piece starts with. `CodeWindow.append_output` and `SSHCommandWidget._insert_output` delete that many characters from the end of the current line, never past its start, as a terminal's backspace does; `TerminalDecoder.feed` passes them through to the view. After a pending `\r` the line is being rewound anyway, and they are dropped. +- **Tests**: `test_code_window.py` +1 and `test_ssh_terminal_output.py` +1: the spinner's frames, then four backspaces and `done`, leave `line` and `done`; in the SSH terminal a bold escape between frames does not get in the way. +- **Result / numbers**: 2090 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/terminal_text.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_ssh_terminal_output.py`, `progress.md` (#105 removed), `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-221 · 2026-09-24 · Keep the HAR tab's output the script of the file and target shown · #fix #har + +- **What**: three ways the HAR tab's output (what Save, Copy and Open in editor take) belonged to something no longer shown. + - Loading another file reset the list and the summary but kept the previous file's script, and Save wrote it. + - A file that could not be read showed the error but left the previous file's requests listed, and Generate made their script under the error. + - Choosing another target did not generate again, while Save names the file after the target: generated for `requests` and switched to the APITestka action list, Save offered `actions.json` and wrote Python into it. The cURL tab already generates again on a target change. +- **Fix**: a successful load clears the output; `_report_error` also clears the listed requests; the target selector's `currentIndexChanged` generates again from the requests the output was made from (`_generated_from`), and does nothing when there is no output yet. +- **Tests**: `test_har_import_gui.py` +4, one per case above and one that a target chosen before generating generates nothing. +- **Result / numbers**: 2094 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_har_import_gui.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-222 · 2026-09-24 · Show a plugin's suffixes in the Run with box as text, and check every box by what it is assigned to · #fix #qt #plugin + +- **What**: running a file through a plugin whose suffixes do not match it shows a message box with the plugin's suffixes and the file's own, set with `msg.setText(...)` as they were; Qt reads markup in them (`AutoText`), so a suffix written as `` was loaded. `test_message_boxes_show_text.py` did not see it: it took an instance box's `setText` only when the receiver's name had "box" in it, and this one is `msg`. +- **Fix**: the text goes through `as_text()`. The guard now also checks the setters of every name a `QMessageBox(...)` is assigned to in the module (`_box_names`). +- **Tests**: `test_message_boxes_show_text.py` +1: `msg = QMessageBox(parent)` then `msg.setText(reason)` is checked. Run against the file before the fix, the guard flags its line 136. +- **Result / numbers**: 2095 tests in 121 files. The full suite before the HAR tab change: 2075 passed, 15 skipped, startup tests exit 0; 442 passed on Python 3.10. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_message_boxes_show_text.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-223 · 2026-09-24 · Percent-encode what a text box would change when the URL Builder builds a URL · #fix #url + +- **What**: JSON to URL encoded the query but wrote the path, fragment, host and user name as given. A fragment `\r` (or U+2029, U+FDD0, a `\r\n`) gave `http://h/p#\r`, which the output box showed, and Copy and Save wrote, as `#\n`: another URL than the one built. A space or a control character does not belong in a URL as it is anyway. +- **Fix**: `build_url` percent-encodes, as UTF-8, every control character (C0, DEL, C1), space, U+2028, U+2029, U+FDD0, U+FDD1 and lone surrogate wherever it is in the URL (`_encode_unwritable`). Anything else is written as given: `%20` already there stays, and `中文` in a fragment is not encoded. +- **Tests**: `test_url_convert.py` +6: `\r`, U+2029 and U+FDD0 in a fragment and a space and newline in a path are encoded; `中文` and an existing `%20` are left alone. Passes on 3.11 and 3.10. +- **Result / numbers**: 2101 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/test_url_convert.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-224 · 2026-09-24 · Hand -b cookies to the header analyzer as the Cookie header curl sends · #fix #curl + +- **What**: the cURL tab's Open headers in analyzer handed over `request.headers` only. Cookies given with `-b` are kept apart in `request.cookies`, so for `curl https://x -b 'c=3; d=4'` the analyzer never saw the `Cookie` header curl sends, and with no other header the button stayed disabled. +- **Checked first**: with both `-H 'Cookie: a=1'` and `-b c=3`, curl 8.7.1 sends only `Cookie: a=1` (it adds the `-b` string only when no custom `Cookie` header is given; seen against a local server), and the generated `requests` script does the same (`requests` writes its own `Cookie` only when none is given). So that case was right, and stays as it is. +- **Fix**: `open_headers_in_analyzer` adds `Cookie: name=value; ...` from `-b` when no `Cookie` header is given; the button is enabled when there are headers or cookies. +- **Tests**: `test_curl_import_gui.py` +2: `-b 'c=3; d=4'` alone enables the button and hands over `Cookie: c=3; d=4`; with `-H 'Cookie: a=1' -b 'c=3'` only `a=1` is handed over. +- **Result / numbers**: 2103 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py`, `test/test_utils/test_curl_import_gui.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-225 · 2026-09-24 · Draw the canvas where it is when the diagram is exported · #fix #diagram + +- **What**: PNG and SVG export scaled and moved the painter to the content's rect, then called `scene.render(painter, QRectF(), rect)`. A null target is the paint device's whole rect, taken in the already moved coordinates, so the scene was mapped a second time: a 200x100 red node at (500, 300) came out as a 63x63 red corner of a 578x378 PNG, and in the SVG it sat under a translate that put it outside the view box. A diagram drawn near the origin looked roughly right, which is why it went unnoticed. +- **Fix**: the painter is left as it is, and `render` gets the target explicitly: the image's rect for PNG (the 2x scale is in the image size), the view box for SVG. +- **Tests**: `test_diagram_editor_widget.py` +2, both failing before the fix: the PNG of a red node away from the origin is red at its middle, a quarter and three quarters across, and white in the margin; the SVG, rendered back, is red at its middle. +- **Result / numbers**: 2105 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-226 · 2026-09-24 · Scale a diagram image from the image as loaded, so resizing does not blur it · #fix #diagram + +- **What**: `DiagramImage.set_size` (a drag on a resize handle) scaled `self._pix_item.pixmap()`, the copy already scaled for the current size, not the image as loaded. Dragged down and back up, a 400x400 image was 50x50 scaled back to 400x400: blurred on the canvas and in exports for good, while a save and reopen, or any undo, showed it sharp again. +- **Fix**: the item keeps the pixmap it was given (`_original`, shared with the scene's cache, no copy) and scales every size from it. +- **Tests**: `test_diagram_images.py` +1: a 400x400 checkerboard resized to 50 and back is pixel for pixel what it was; the same steps done the old way are not. +- **Result / numbers**: 2106 tests in 121 files. The full suite before the export change: 2088 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-227 · 2026-09-24 · Leave a redirect's body and Location unread in the AI panels and the image download · #fix #network #security + +- **What**: the AI panels (CoT review, Skills, AI code review) send with `allow_redirects=False`, but `requests`' `Session.send` still prepares the next request for `Response.next`, and doing so reads the redirect's whole body and parses its `Location`. + - A `Location: http://[bad/x` raised `ValueError`, which none of the three threads catch: the Skills panel stayed on "Generating" for good, the review panel showed only its first line, and the CoT chain stopped half-way without a word. + - A 302 with a 40 MB body was read whole before `read_capped_text` ever ran; only the overall deadline bounded it. + - The diagram's image download has the same gap in urllib: after `_ValidatingRedirectHandler` validates a hop, `http_error_302` reads the redirect's whole body before following it, past `MAX_DOWNLOAD_BYTES`. +- **Fix**: `public_session()` is a `_NoRedirectSession`, whose `resolve_redirects` yields nothing, so a 3xx comes back unread whatever `allow_redirects` says (nothing here follows one; Network rule 4). `_ValidatingRedirectHandler.redirect_request` closes the redirect response once the hop is validated, so the read after it gets nothing. +- **Tests**: `test_public_http.py` +3, against local servers: a 302 claiming 1 GB with `Location: http://[bad/x` or a good one comes back as a 302, `next` empty, body unread; the image opener follows a 302 claiming 1 GB to the image. Done the old way, the first raises `ValueError` and the second `IncompleteRead`. +- **Result / numbers**: 2109 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/network/public_http.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py`, `test/test_utils/test_public_http.py`, `CLAUDE.md` (Network rule 4), `architecture_explore.md` §7, §12, §18 +- **Open items**: none. + +## U-20260923-228 · 2026-09-24 · Give pip the prthinker source folder as a path, not a name · #fix #prthinker + +- **What**: `install_target` accepted a relative source folder typed in the settings (`prthinker`, checked against the working directory) and gave pip `prthinker[runner]`. With no path separator in it, pip reads that as a requirement for the package on PyPI, not as the folder: the Install action fetched `prthinker` from PyPI (or failed), and a project published under that name would have been installed in the user's source's place. An absolute path is read as a folder (`file:///...`). +- **Fix**: the folder goes to pip as `path.absolute()`. +- **Tests**: `test_prthinker_setting.py` +1: with the working directory holding a `prthinker` source folder, `install_target("prthinker")` gives that folder's absolute path with `[runner]`. +- **Result / numbers**: 2110 tests in 121 files. The full suite before this change: 2094 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-229 · 2026-09-24 · Run with the IDE's own interpreter when no venv is found, not whatever python3 is on PATH · #fix #executor + +- **What**: with no interpreter chosen and no `venv`/`.venv` in the working folder, every automation run, prthinker review, TestPioneer run and pip install went to JEditor's last resort, `shutil.which("python3")` then `python`. On Windows that is often the Microsoft Store stub (`WindowsApps\python3.EXE`, on this machine too), which exits 9009 and prints nothing: the run window said only "Task exit with code 9009", and an install gave no reason. A real interpreter found there seldom has the automation packages, which are the IDE's own dependencies, and pip installed into it rather than the one the IDE runs on. The JupyterLab tab already falls back to the IDE's own interpreter (`choose_python`). +- **Fix**: `default_interpreter()`: a `venv`/`.venv` in the working folder, else `sys.executable`; only a packaged build (`sys.frozen`, where `sys.executable` is the app) still searches PATH through `check_and_choose_venv`. `renew_path` uses it. +- **Tests**: `test_build_task_process.py` +2: with no venv the IDE's own interpreter runs it; a packaged build still looks on PATH. The three tests of "no Python found" now set up a packaged build, the only case left where it can happen, and the working-folder test gives a real folder. +- **Result / numbers**: 2112 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `test/test_utils/test_build_task_process.py`, `test_task_manager_venv.py`, `test_run_output.py`, `README.md`, `architecture_explore.md` §4, §18 +- **Open items**: none. + +## U-20260923-230 · 2026-09-24 · Keep difflib's junk heuristic on for what is left after trimming, so a repetitive diff stays quick · #fix #diff #perf + +- **What**: U-20260923-213 set the unchanged head and tail aside and turned `SequenceMatcher`'s junk heuristic off when fewer than 2,000 lines were left to match. Without it, matching repetitive text grows with the square of its lines: 998 pairs of `}` and a changed line (1,996 lines) took 55.6 s, where `difflib` as it was takes 1 ms, and 2,002 lines took 5 ms: a cliff at the threshold. Found reviewing this round's own changes. +- **Fix**: the heuristic stays as `difflib` sets it (on from 200 lines). The trimming alone is what fixed the one-line change in a long repetitive text, and it still does. `_AUTOJUNK_FROM` is gone. The docstring now says that `ratio()` and `get_matching_blocks()` describe only what is left to match (nothing calls them). +- **Tests**: `test_text_diff.py` +1: those 1,996 lines compare in under 5 s. The property that the diff turns the left into the right and is never longer than `difflib`'s (300 examples) and the two one-changed-line cases still pass. +- **Result / numbers**: the worst case under 200 lines (198 alike) takes 0.04 s. 2113 tests in 121 files. The full suite before this change: 2097 passed, 15 skipped, startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-231 · 2026-09-24 · Generate the HAR script again when going back from a target that could not carry it · #fix #har + +- **What**: U-20260923-221 made a target change generate the HAR script again, but only while there was output (`_generated_code`). A target that cannot carry the requests (the APITestka action list and a recorded file upload) leaves none, so going back to one that can did nothing: the selector said `requests`, the output still showed the APITestka error, and Save stayed off. Found reviewing this round's own changes. +- **Fix**: `_regenerate` goes by what was generated from (`_generated_from`), which a failed target keeps. +- **Tests**: `test_har_import_gui.py` +1: an upload generated for `requests`, switched to the action list (no output), and back gives the same `requests` script, with Save on again. +- **Result / numbers**: 2114 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_har_import_gui.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-232 · 2026-09-24 · Call an encrypted SSH key of a type paramiko cannot load unsupported, not its passphrase wrong · #fix #ssh + +- **What**: U-20260923-217 took a key file that asks for a passphrase as proof that a failed load was the passphrase's fault. An encrypted key in OpenSSH format asks before paramiko looks at its type, so one paramiko 4 cannot load (DSA; FIDO `sk-` keys likely too) was reported as having a wrong passphrase even with the right one, where it used to say "unsupported key". Found reviewing this round's own changes. +- **Fix**: with a passphrase given, `unloadable_key_reason` asks `cryptography` whether it decrypts the file (`load_ssh_private_key`, then `load_pem_private_key`; `UnsupportedAlgorithm` counts as decrypted): if it does, the key is unsupported; if not, the passphrase is wrong. `cryptography` is paramiko's own dependency, so it is there wherever the SSH tabs are. Its DSA deprecation warning is silenced for this check. +- **Tests**: `test_ssh_security.py` +1: an encrypted DSA key in OpenSSH format does not load, is unsupported with the right passphrase and has a wrong passphrase with another. The four RSA, Ed25519 and ECDSA cases still report a missing and a wrong passphrase. +- **Result / numbers**: 2115 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_key_loader.py`, `test/test_utils/test_ssh_security.py`, `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-233 · 2026-09-24 · Round a long decimal epoch down inside the decimal arithmetic too · #fix #timestamp + +- **What**: `_decimal_microseconds` (U-20260923-207) worked to 60 digits in the default rounding (half even) and then took the floor. A fraction longer than that rounded up first: `0.` followed by 70 nines gave `00:00:01`, not `00:00:00.999999`, against the "rounded toward the past" the result promises. The float path before it did the same, so it was not a regression; found reviewing this round's own changes. +- **Fix**: the decimal context rounds down (`ROUND_FLOOR`) as well: a result rounded down never passes the whole number below the true one, so the floor after it is exact. +- **Tests**: `test_timestamp_converter.py` +1: 70 nines give `.999999`. +- **Result / numbers**: 2116 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-234 · 2026-09-24 · Never write a known_hosts file over when it cannot be read, and keep its bytes · #fix #ssh + +- **What**: three things U-20260923-218 got wrong in `ssh_host_key_policy.py`, found reviewing this round's own changes. + - `_store` built the new file from `_file_lines`, which gives no lines for a file that exists but cannot be read (locked by another program, no permission). Accepting a host then replaced the whole file with that one line: every host trusted so far was gone. + - The lines were decoded with replacement, so a byte that is not UTF-8 (in a comment, say) was written back as U+FFFD for good. + - `apply_host_key_policy` loaded `~/.ssh/known_hosts` first and PyBreeze's file after it, so for one host and key type PyBreeze's key replaced the system one, where paramiko had checked the system file first. +- **Fix**: `_store` reads the file as bytes and appends the new line to them (still replaced in one step through `replace_written`); a read that fails is an `OSError` that stores nothing and is logged, as a failed write already was. The system file is loaded last, so it wins again. +- **Tests**: `test_ssh_host_key_policy.py` +2: a `\xe9` in a comment is still there after a host is accepted; with the file unreadable, accepting a host leaves it as it was. +- **Result / numbers**: 2118 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `test/test_utils/test_ssh_host_key_policy.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-235 · 2026-09-24 · Skip a HAR entry holding half a character instead of failing the whole file · #fix #har + +- **What**: a HAR file is JSON, and JSON's `\ud800` escape gives a lone surrogate: half a character, which no request can carry. In a URL's query or a `queryString` value it raised `UnicodeEncodeError` from `parse_har` (the query check and the entry's summary encode it), and the HAR tab catches only `HarParseException`, so the whole file failed out of the slot; the LoadDensity target raised it again while generating. Found by throwing random text at every pure-logic entry point; the other JSON tools (URL, query, JSON format, JWT, response inspector, a cURL JSON body) already handle it. +- **Fix**: `parse_har` skips an entry whose recorded request holds a lone surrogate anywhere (`_is_unicode`: the request as JSON, encoded as UTF-8), as it skips one with a malformed URL or method, and logs it without the content. +- **Tests**: `test_har_import.py` +3: a lone surrogate in the URL's query, in a `queryString` value or in a header skips that entry, and the good one beside it loads. +- **Result / numbers**: 2121 tests in 121 files. The full suite before this change: 2103 passed, 15 skipped, startup tests exit 0, gates clean, 510 passed on Python 3.10. `ruff check` clean. +- **Files**: `pybreeze/utils/har_import/har_parser.py`, `test/test_utils/test_har_import.py`, `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-236 · 2026-09-24 · Apply backspaces in one pass, so a long rub-out does not hold the UI · #fix #output #perf + +- **What**: `strip_terminal_controls` applied backspaces by removing a character and the backspace after it with a regex, pass after pass, until nothing changed. Each pass takes back one pair where the characters and the backspaces meet, so a run of N characters and N backspaces took N passes over the text: 2,000 took 44 ms and 20,000 took 5.2 s. It runs on the UI thread for every piece of the run window and the SSH terminal. Found timing every pure tool at two sizes; the others grow about linearly at any size they meet. +- **Fix**: `_apply_backspaces` goes through the text once with a stack, with the same rule (a backspace takes back the character before it, never a line break, and one with nothing to take back stays for the control-character filter); text without a backspace skips it. +- **Tests**: `test_ansi_escape.py` +2: on 500 random texts of `a`, `b`, newlines and backspaces it gives what the pass-after-pass way gave; 10,000 characters and 10,000 backspaces take under a second. +- **Result / numbers**: the 20,000-character case now takes 2 ms. 2123 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/utils/terminal_text.py`, `test/test_utils/test_ansi_escape.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-237 · 2026-09-24 · SFTP tree speaks the IDE language, and an entry named ... is not its placeholder · #fix #ssh #i18n + +- **What**: the SFTP tree's context menu and column headers were English literals while both language dictionaries held their `ssh_file_viewer_context_menu_action_*` and `ssh_file_viewer_tree_header_*` entries, unused. A folder's not-yet-listed row was recognised by its text `...`, so a server entry named `...` (a valid name) listed first in a folder was taken for it: expanding the folder again removed the entry and listed the folder again. Right-clicking the placeholder or the loading row acted on a path of `''` (Delete asked about `''`, Rename sent `("", "/name")`). Found listing the dictionary keys no code reads. +- **Fix**: the menu and the headers read the dictionaries; the placeholder carries `PLACEHOLDER_ROLE` item data and is recognised by it; a right-click on a row without a path acts on the folder it is in. +- **Tests**: `test_sftp_tree_actions.py` +4 (an entry named `...` is kept on a second expand; an unexpanded folder still has its placeholder; the menu and headers in Traditional Chinese; the placeholder's menu acts on its folder), three of which fail on the old code. +- **Result / numbers**: 2127 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-238 · 2026-09-24 · The SSH shell's connect line in the IDE language, and its status Connected · #fix #ssh #i18n + +- **What**: once the shell was open its output read `Connected to host:22 as user`, the ` as ` in English whatever the IDE spoke (`已連線至 host:22 as user`), and an IPv6 address ran into its port (`::1:22`). The status label was set from the same key, reading `Connected to` / `已連線至` with nothing after it, while `ssh_command_widget_status_label_connected` went unused. Found listing the dictionary keys no code reads. +- **Fix**: `ssh_command_widget_log_message_connected` is one format string, `Connected to {host}:{port} as {user}` / `已以 {user} 身分連線至 {host}:{port}`; an address with a colon is shown in brackets; the status label uses `ssh_command_widget_status_label_connected`. +- **Tests**: `test_ssh_terminal_output.py` +2 (a host name and `::1`, in Traditional Chinese: the line and the status), which fail on the old code. +- **Result / numbers**: 2129 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_terminal_output.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-239 · 2026-09-24 · Why JupyterLab did not start, in the IDE language · #fix #jupyter #i18n + +- **What**: the JupyterLab tab shows why the server did not start after its translated `jupyterlab_init_failed`, but the two reasons the launcher itself gives were English literals: `JupyterLab startup timeout (60s)` and `JupyterLab exited early (code 1): ...`. `jupyterlab_timeout` was in both dictionaries and never read. Found listing the dictionary keys no code reads. +- **Fix**: `_wait_until_ready` builds the timeout from `jupyterlab_timeout` (English now `JupyterLab did not start in time`) and the early exit from a new `jupyterlab_exited_early` format string (`{code}`, `{output}`) in both languages. +- **Tests**: `test_jupyter_ready.py` +2 (both reasons in Traditional Chinese; server output holding `{0}` is kept as written), which fail on the old code; its fixture now merges the dictionaries itself, as the module no longer ran alone otherwise. +- **Result / numbers**: 646 keys in each language. 2131 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_jupyter_ready.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-240 · 2026-09-24 · A Save As button in the diagram editor · #fix #diagram #ui + +- **What**: the diagram editor's file row had New, Open, Save and Import. Save As was reachable only through Ctrl+Shift+S, so a diagram saved once had no way from the toolbar to a new file, while `diagram_editor_action_save_as` (`Save As` / `另存新檔`) sat unused in both dictionaries. Found listing the dictionary keys no code reads. +- **Fix**: `_add_file_buttons` adds a Save As button after Save, calling `_save_as_diagram`. +- **Tests**: `test_diagram_editor_widget.py` +1 (with a current file, the button asks for a path and writes there, leaving the first path alone), which fails on the old code. +- **Result / numbers**: 2132 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-241 · 2026-09-24 · Drop five translations nothing shows · #cleanup #i18n + +- **What**: five keys were in both dictionaries with no code reading them, directly or by a built name: `ssh_file_viewer_log_message_refreshing`, `ssh_file_viewer_dialog_label_input_text`, `cot_gui_error_read_file`, `skills_finished_signal` and `skills_error_signal` (the last two described signals, not text anyone sees). The remaining keys no PyBreeze code reads by their full name are read by a built name (`header_finding_*`, `header_analyzer_level_*`, the SFTP menu and headers) or by JEditor (`plugin_browser_*`, `tools_menu_re_edge_gpt_*`). +- **Fix**: removed from `extend_english.py` and `extend_traditional_chinese.py`. +- **Tests**: `test_language_parity.py` and `test_startup_language.py` pass unchanged. +- **Result / numbers**: 641 keys in each language. +- **Files**: `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-242 · 2026-09-24 · The CoT review's last English words in Traditional Chinese · #fix #ai #i18n + +- **What**: in Traditional Chinese the CoT review panel still showed English: its window title (`Prompt Sender UI`), the start of each failed file's line in the answer view (`Error sending:`) and the title of the warning for an empty URL, a `"Warning"` literal in the code. The save dialog of every tool tab offered `Text (*.txt)`. Found scanning the Traditional Chinese dictionary for values with no Chinese in them. +- **Fix**: `cot_gui_window_title` (`Prompt 傳送介面`), `cot_gui_error_sending` (`傳送失敗:`) and `output_actions_filter_text` (`文字檔 (*.txt)`) translated; the warning takes its title from a new `cot_gui_warning_title` (`Warning` / `警告`). The Chinese values still without Chinese are names, URLs, examples and file filters. +- **Tests**: `test_ai_gui_lifecycle.py` +1 (the empty-URL warning in Traditional Chinese, title and text). +- **Result / numbers**: 642 keys in each language. 2133 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ai_gui_lifecycle.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-243 · 2026-09-24 · Multi-script menu entries read as Chinese, without a word twice · #fix #i18n + +- **What**: in Traditional Chinese the automation menus of Autocontrol, FileAutomation, LoadDensity and WebRunner offered `運行 Multi WebRunner 腳本` and `運行 Multi WebRunner 腳本 腳本並寄信`: an English word, and in the four send-mail entries `腳本` twice. APITestka's own entries already read `運行多個 APITestka 腳本`. Found scanning the Traditional Chinese dictionary for a word written twice. +- **Fix**: the eight entries read `運行多個 腳本` and `運行多個 腳本並寄信`. `運行` and `插件` stay: JEditor's own Run and Plugins menus use them, and PyBreeze's entries sit beside those in the same menu bar. +- **Tests**: `test_language_parity.py` +1 (no value in either language writes a word twice, before a space or directly before Chinese text), which fails on the old dictionary. +- **Result / numbers**: 2134 tests in 121 files. +- **Files**: `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_language_parity.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-244 · 2026-09-24 · A link to a folder opens as a folder in the SFTP tree · #fix #ssh + +- **What**: an SFTP listing describes a symbolic link itself (OpenSSH's server reads each entry with `lstat`), and the tree took everything that was not a directory for a file. A link to a folder -- `/bin`, `/lib`, `/sbin` at the root of most current Linux systems, or a `current` release link -- showed as a file of a few bytes: it could not be opened, and Download fetched a folder. Found reading the tree for logic keyed on what a row shows. +- **Fix**: `SFTPClientWrapper.list_dir` looks each link up with `stat` (`_follow_link`, in the same hold of the session) and gives the entry its target's type and size; a link leading nowhere stays a link and shows as a file, as before. +- **Tests**: `test_sftp_listing.py` +4 (a link to a folder is a folder; a link to a file carries the file's size; a dangling link stays a link; only links are looked up). +- **Result / numbers**: 2138 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py`, `test/test_utils/test_sftp_listing.py`, `architecture_explore.md` (line counts re-measured; the tree's had not been updated with U-20260924-237) +- **Open items**: none. + +## U-20260924-245 · 2026-09-24 · Remove the SFTP tree's event filter nothing installs · #cleanup #ssh + +- **What**: `SSHFileTreeManager.eventFilter` pressed an OK button on Enter in an "improvised input dialog" that no longer exists: names are asked for with `QInputDialog.getText`, which handles Enter itself, and nothing installs the filter. It was the only reader of `ssh_file_viewer_dialog_button_ok`, and the only user of three imports. +- **Fix**: removed the method, the imports (`QEvent`, `QLineEdit`, `QPushButton`) and the key from both dictionaries; `get_text`'s docstring says what it does. +- **Tests**: the SFTP tree tests pass unchanged. No behaviour change. +- **Result / numbers**: 641 keys in each language. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-246 · 2026-09-24 · The SFTP tree tells folders from files by item data, not by its Type column · #refactor #ssh + +- **What**: seven places in the SFTP tree decided folder or file by comparing the Type column's text with `"dir"` / `"file"`, so that column could not be translated without breaking Refresh, Delete, Download, Upload and folder creation. +- **Change**: `make_item` stores the kind under `KIND_ROLE`; `is_folder()` and `is_file()` read it, and `folder_item()` and the menu actions use them. The column still shows `dir` / `file`. +- **Tests**: the SFTP tree, listing, upload and teardown tests (63) pass unchanged. No behaviour change. +- **Result / numbers**: `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-247 · 2026-09-24 · The SFTP tree's Type column in the IDE language · #fix #ssh #i18n + +- **What**: the SFTP tree's Type column showed `dir` and `file`, internal words, in English whatever the IDE spoke. With the kind kept in item data (U-20260924-246) the column no longer drives anything. +- **Fix**: `make_item` shows `ssh_file_viewer_type_dir` / `ssh_file_viewer_type_file` (`Folder` / `File`, `資料夾` / `檔案`). +- **Tests**: `test_sftp_tree_actions.py`'s Traditional Chinese test also checks the Type column and that the translated rows are still a folder and a file. +- **Result / numbers**: 643 keys in each language. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_tree_actions.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-248 · 2026-09-24 · SFTP Rename starts from the current name · #fix #ssh #ui + +- **What**: Rename in the SFTP tree opened an empty box, so changing one letter of a name meant typing all of it; and giving the same name sent a rename of the entry onto itself, which some servers answer with a failure. +- **Fix**: `get_text` takes the text to start from and passes it to `QInputDialog.getText`; Rename starts from the entry's name, and a name that comes back unchanged (spaces aside) sends nothing. +- **Tests**: `test_sftp_tree_actions.py` +3 (the box starts from the current name; the same name sends nothing; the dialog is given the starting text). +- **Result / numbers**: last full unit run 2119 passed, 15 skipped (as of `cece5e6`). 2141 tests in 121 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-249 · 2026-09-24 · Say why an SFTP folder was not deleted · #fix #ssh #ui + +- **What**: SFTP removes only an empty folder. Deleting one with anything in it, OpenSSH answers with a bare `Failure`, and the tree said `Error: Failure`, leaving the user to guess. +- **Fix**: the tree removes a folder through `_remove_folder`, which, when the server refuses, raises the refusal again as `ssh_file_viewer_message_folder_not_removed`: the folder was not deleted, SFTP deletes only an empty folder, and what the server said. A refused file delete is shown as before. +- **Tests**: `test_sftp_tree_actions.py` +2 (a refused folder delete says why and leaves the tree as it was; a refused file delete shows the server's reason alone). +- **Result / numbers**: startup tests exit 0, gates and `ruff check` clean on `6ebd28a`. 644 keys in each language. 2143 tests in 121 files. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_tree_actions.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-250 · 2026-09-24 · SSH login: Enter connects, and the key file can be browsed for · #feature #ssh #ui + +- **What**: the SSH tab's login form needed the mouse for Connect, and the private key's path had to be typed in full. +- **Change**: Enter in the host, user, password or key field clicks Connect. A Browse button beside the key field opens a file dialog in `~/.ssh` (the home folder when there is none); a chosen file fills the field and ticks key authentication, and cancelling changes nothing. +- **Tests**: new `test_ssh_login_form.py` (7: Enter in each of the four fields; a chosen file fills the path and ticks the box; the dialog starts in `~/.ssh`; cancelling leaves what was typed). +- **Result / numbers**: 646 keys in each language. 2150 tests in 122 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_login_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_ssh_login_form.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-251 · 2026-09-24 · Enter in the regex pattern runs it · #feature #tools #ui + +- **What**: the Regex Tester ran a pattern only from its Test button; Enter in the one-line pattern field did nothing, while the Timestamp tab's field converts on Enter. +- **Change**: `pattern_edit.returnPressed` runs `test()`, which already ignores a second start while a match is running. +- **Tests**: `test_regex_gui.py` +1 (Enter in the field starts a match and its result is shown), which fails on the old code. +- **Result / numbers**: 2151 tests in 122 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `test/test_utils/test_regex_gui.py`, `README.md`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-252 · 2026-09-24 · Reveal in file explorer shows a file selected, and says when it cannot · #fix #editor #ui + +- **What**: the project tree's "Reveal in file explorer" opened a file's folder, not the file: in a folder of hundreds the user still had to find it. And the file manager was started with nothing around it, so a missing `xdg-open` (a Linux desktop without it) or a failed start raised out of the menu's slot as a traceback, with nothing shown. +- **Fix**: `reveal_command()` builds the command: Explorer (from `SystemRoot`) with `/select, `, Finder with `open -R `, and `xdg-open` on the folder elsewhere, as `xdg-open` cannot select; a folder is opened as before. The action runs it through `_perform_file_op`, so an `OSError` is shown in the tree's error dialog. `os.startfile` is no longer used. +- **Tests**: `test_file_tree_context_menu.py` +7 (a file is selected on Windows and macOS; a folder is opened on all three; elsewhere a file's folder opens; Explorer comes from `SystemRoot`; a missing file manager is shown, not raised). The command line for a path with spaces was checked: `explorer.exe /select, "C:\...\c d.txt"`. +- **Result / numbers**: 2158 tests in 122 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-253 · 2026-09-24 · The AI review panel lists its methods from one place · #refactor #ai + +- **What**: the AI code review panel's method box listed `GET`, `POST`, `PUT`, `DELETE` as a literal next to `SUPPORTED_METHODS`, which `send_request` checks against; a method added to one and not the other would be offered and then refused, or never offered. +- **Change**: the box is filled from `SUPPORTED_METHODS`. +- **Tests**: the panel's tests (40) pass unchanged. No behaviour change. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` +- **Open items**: none. + +## U-20260924-254 · 2026-09-24 · Why a tool refused its input, in the IDE language · #fix #tools #i18n + +- **What**: the pure tools raise their reasons from the English constants in `exception_tags`, and every tool tab showed them as they were inside a translated sentence: in Traditional Chinese the Timestamp tab read `轉換失敗:not a recognized epoch number or ISO-8601 date-time`, and so did the cURL, HAR, JSON, JWT, Query, Regex and URL tabs with their own 33 reasons. Found scanning the text the tool tabs show. +- **Fix**: `utils/exception/error_templates.py` makes every constant whose name ends in `_error` a language-dictionary entry, `error_text_` (fields written without a conversion, `{key!r}` as `{key}`); the English dictionary takes the constants themselves, so nothing is copied, and the Traditional Chinese dictionary has the 33 translations. `pybreeze_ui/error_text.error_text()` finds the constant a message was made from (the one with the most fixed text first), the values put into it and any detail after it (`... (Expecting value)`), and gives the entry in the IDE language; a message from anywhere else is shown as it is. The tools still log the English. Ten display sites in eight tabs use it. +- **Tests**: `test_error_text.py` (40): every constant has a Chinese translation and is found again from a message made from it; a repr'd value, a detail after the constant and braces in a value are kept; a message from elsewhere and English are unchanged; the Timestamp tab shows its reason in Chinese. +- **Result / numbers**: 679 keys in each language (33 of them tool errors). 2198 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/error_templates.py` (new), `pybreeze/pybreeze_ui/error_text.py` (new), `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `pybreeze/pybreeze_ui/tools_gui/` (`curl_import_gui.py`, `har_import_gui.py`, `json_format_gui.py`, `jwt_decoder_gui.py`, `query_json_gui.py`, `regex_gui.py`, `timestamp_gui.py`, `url_builder_gui.py`), `test/test_utils/test_error_text.py` (new), `CLAUDE.md`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-255 · 2026-09-24 · A plugin run config's args given as a string is one argument · #fix #plugins #run + +- **What**: a plugin run config's `args` is meant to be a sequence, but JEditor does not check what a plugin registers. Given as a string (`"args": "run"`), "Run with..." took it one character at a time and ran `go r u n main.go`; a number raised `TypeError` out of the menu. Found reading the run path for what it assumes about a plugin's config. +- **Fix**: `file_runner_process.run_arguments()`: a string is one argument (none when empty), a list or tuple gives its items as text, and anything else gives none. +- **Tests**: `test_compile_then_run.py` +8 (a tuple, a list, a string, an empty string, none given, `None`, a number, items that are not text). +- **Result / numbers**: 2207 tests in 123 files, measured by collection. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_compile_then_run.py`, `architecture_explore.md` §18 +- **Open items**: JEditor's own runner (`code_exec.py`, `list(run_config.get("args", ()))`) has the same fault; recorded, not changed here. + +## U-20260924-256 · 2026-09-24 · A HAR file that is not one says why in the IDE language too · #fix #tools #i18n + +- **What**: U-20260924-254 put the tool tabs' reasons through `error_text()`, but missed the HAR tab's own load error, whose `format(` call runs over two lines: a file that is not a HAR export still read `匯入失敗:the JSON has no log.entries list, so it is not a HAR export` in Traditional Chinese. +- **Fix**: `HarImportGUI.load_text` shows the reason through `error_text()`. No tool tab now shows `str(error)` as it is. +- **Tests**: `test_error_text.py` +1 (a JSON document without `log.entries` loaded in Traditional Chinese). +- **Result / numbers**: 2208 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-257 · 2026-09-24 · Drop TaskProcessManager's unused error hook · #cleanup #executor + +- **What**: `TaskProcessManager` took an `error_trigger_function` that nothing passed and nothing called: a run that fails reports its exit code and then runs the done hook, as any run does. Its `__init__` also called `super().__init__()` on `object` and carried a stale `# ite_instance param` comment. +- **Change**: the parameter, the attribute, the call and the comment are gone; `task_done_trigger_function` is typed `Callable | None`, as it may be. Only this repository constructs the class (`process_executor_utils.build_process`), and it is not a contract in `architecture.md` §6. +- **Tests**: the executor tests (112 passed, 14 skipped) pass unchanged. No behaviour change. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py` +- **Open items**: none. + +## U-20260924-258 · 2026-09-24 · Refactor: the network, image, host-key and Skills messages become exception_tags constants · #refactor #i18n + +- **What**: the reasons URL validation, the capped reader, `describe_request_error`, the diagram editor's image download, the SSH host-key check and the Skills panel's status text give were English literals where they were raised, so `error_text()` (U-20260924-254) could not find them. They are 23 constants in `exception_tags` now, with the same text, raised through `.format()`; the Skills panel's `_where` fragment became `_redirect_text`, a whole message per case. +- **Behaviour**: none visible: every message reads as before, and nothing shows them through `error_text()` yet. Their `error_text_` entries are in both dictionaries (English from the constants, Traditional Chinese translated) for the next step. +- **Tests**: `test_error_text.py` fills every field a constant has, so each of the 23 is also found again from a message made from it (+23). The URL, request-error, capped-read, image, SSH and Skills tests pass unchanged. +- **Result / numbers**: 702 keys in each language. 2231 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/exception_tags.py`, `pybreeze/utils/network/url_validation.py`, `pybreeze/utils/network/http_client.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_net_utils.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_error_text.py`, `README.md`, `architecture_explore.md` +- **Open items**: showing them in the IDE language (next entry). + +## U-20260924-259 · 2026-09-24 · Network, image, host-key and Skills reasons in the IDE language · #fix #i18n #network + +- **What**: after U-20260924-258 made them constants, the reasons were still shown in English inside translated text: an image the diagram editor could not download (`Image exceeds 20 MB limit.`), a URL the SSRF check refused (`Scheme 'ftp' is not allowed. Use http or https.`), a request that failed (`the request timed out (ReadTimeout)`) in the AI code review, CoT review and Skills panels, the Skills panel's redirect, 401/403 and 5xx texts, and a host key the user declined in the SSH shell. +- **Fix**: `ImageDownloadThread`, `AICodeReviewClient`'s request thread, `code_review_thread`, the Skills `RequestThread` and `describe_failed_status`, and `SSHCommandWidget._on_connect_failed` pass the reason through `error_text()`. A server's own text (an error page's body, paramiko's messages) is shown as it is. +- **Tests**: `test_error_text.py` +8 (the four Skills texts, a failed image download, an SSRF refusal, a timed-out request and a declined host key, all in Traditional Chinese), six of which fail on the old code. +- **Result / numbers**: 2239 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: the SFTP tree's connect-failure box (`ssh_file_viewer_widget._on_connect_failed`) still shows a declined host key in English; that file had other uncommitted work in it at the time (progress.md). + +## U-20260924-260 · 2026-09-24 · The SFTP menu opens with exec, not the deprecated exec_ · #cleanup #ssh #qt + +- **What**: the SFTP tree opened its context menu with `QMenu.exec_`, the Qt 5 spelling PySide6 keeps only as a deprecated alias; the rest of the code base uses `exec`. +- **Change**: `menu.exec(...)`. The tree's menu tests replaced `QMenu.exec_` on the class; PySide6 does not look up an `exec` patched onto `QMenu` itself (the real, modal menu opened and the test waited forever), so they now put a `QMenu` subclass whose `exec` answers at once in the module's place. +- **Tests**: `test_sftp_tree_actions.py` (29) passes. No behaviour change. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_sftp_tree_actions.py` +- **Open items**: none. + +## U-20260924-261 · 2026-09-24 · A Stop button in the run window · #feature #run #ui + +- **What**: a run window had no way to stop what it showed. Closing it lets the run go on, by design (the rest of its output goes there), and `CodeWindow.stop_runner()` was called only when the IDE closed, so a script stuck in a loop -- an automation run waiting on a page that never loads, say -- could be stopped only by quitting the IDE. +- **Change**: the run window has a Stop button above its output that calls `stop_runner()` (the child and every process it started, `stop_tree`). The executors call the new `CodeWindow.run_started()` once their child is running, which turns it on; `run_ended()` turns it off. A compile-then-run turns it on for the compile and again for the run. +- **Tests**: `test_code_window.py` +3 (off until a run starts and after it ends; it stops the runner; a script that would run forever is stopped through the button and the window's run ends). +- **Result / numbers**: executor and run-window tests 172 passed, 14 skipped. 703 keys in each language. 2242 tests in 123 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/extend/process_executor/file_runner_process.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_code_window.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-262 · 2026-09-24 · The SFTP context menu dispatches from a table · #refactor #ssh + +- **What**: the SFTP tree's context menu kept a variable per action and chose its handler through a chain of six `elif` branches, all taking the same item; a new entry meant three edits. +- **Change**: one table of (name, handler); each action added from it maps to its handler, and the chosen one is called with the item. The labels and the order are unchanged. +- **Tests**: `test_sftp_tree_actions.py` (29) passes unchanged. No behaviour change. +- **Result / numbers**: startup tests exit 0, gates and `ruff check` clean on `dd2a55b`. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` +- **Open items**: none. + +## U-20260924-263 · 2026-09-24 · An SFTP transfer can be cancelled · #feature #ssh #ui + +- **What**: an SFTP download or upload, once started, ran to its end: closing the tab lets it run out by design (cut off, it would leave half a file), and nothing else could stop it. A multi-gigabyte download started by mistake held the session, and every other transfer, until it finished. +- **Change**: while a transfer runs, the tree's context menu has "Cancel the transfer" (`取消傳輸`). `SftpTransferThread.cancel()` sets a flag that the progress callback given to paramiko's `get` / `put` checks at every block, raising `TransferCancelled`; the partial file (`..*.part`, here or on the server) is removed as for a failed transfer, a file it was to replace is left as it was, and the thread sends `cancelled`, which the tree shows as a message. `SFTPClientWrapper.download` / `upload` take the callback as `progress`. +- **Tests**: new `test_sftp_cancel.py` (3: a cancelled download removes what arrived and keeps the old file; a cancelled upload keeps the server's copy; the menu offers the cancel only while a transfer runs, and it stops it with the message). The stand-in SFTP clients in `test_sftp_session_sharing.py`, `test_sftp_upload.py` and `test_ssh_teardown.py` take the callback. +- **Result / numbers**: SFTP and SSH tests 203 passed. 706 keys in each language. 2245 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py`, `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/extend_english.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `test/test_utils/test_sftp_cancel.py` (new), `test/test_utils/test_sftp_session_sharing.py`, `test/test_utils/test_sftp_upload.py`, `test/test_utils/test_ssh_teardown.py`, `README.md`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-264 · 2026-09-24 · A diff never changes more lines than difflib's own · #fix #diff + +- **What**: `compare_texts` set the unchanged head and tail aside before matching (`_TrimmedMatcher`, U-20260923-213), which keeps one changed line in a long repetitive text to one line, but can also leave a worse match: `b b a b a` against `b a c b` came out as five lines changed where `difflib` changes three (`-b`, `+c`, `-a`), the first `b` having been taken as unchanged. Found by the Hypothesis property in `test_text_diff.py` ("never longer than difflib's"), which failed on this example in a full run. +- **Fix**: `_closest_match` makes the plain `SequenceMatcher` match too whenever anything was set aside, and keeps the one that changes fewer lines; the counts and the diff come from the one kept. With no head or tail in common the two are the same and it matches once. The plain match keeps difflib's junk heuristic, so a repetitive text stays quick. +- **Tests**: `test_text_diff.py` +1 (that example: one line added, two removed, and the diff turns the left into the right), which fails on the old code; the property and the 2,000-line timing test pass. +- **Result / numbers**: `test_text_diff.py` runs in about 4 s. 2246 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/utils/diff_tools/text_diff.py`, `test/test_utils/test_text_diff.py`, `architecture_explore.md` +- **Open items**: none. + +## U-20260924-265 · 2026-09-24 · The SFTP tree says a host key was declined in the IDE language · #done #fix #ssh #i18n + +- **What**: progress #107. After U-20260924-259 the shell half of the SSH tab said a declined host key in the IDE language, but the SFTP tree's connect-failure box still read `連線失敗: Host key for example.org rejected by user.`: `SSHFileTreeManager._on_connect_failed` put the error in as it was. It was left out then because the file held other uncommitted work. +- **Fix**: the box passes the reason through `error_text()`; paramiko's own messages still show as they are, through `as_text()`. +- **Tests**: `test_error_text.py` +1 (the tree's connect failure for a declined host key, in Traditional Chinese). +- **Result / numbers**: 2247 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `test/test_utils/test_error_text.py`, `progress.md`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-266 · 2026-09-24 · A test that no tool tab shows a reason as it was raised · #test #i18n + +- **What**: U-20260924-256 fixed a tool tab (HAR) left showing `str(error)` as it was after U-20260924-254, missed because its `format(` call ran over two lines. Nothing would catch the next one. +- **Fix**: `test_error_text.py` reads every module in `pybreeze_ui/tools_gui/` and fails on `error=str(error)` (or `err`, `exc`, `e`), whatever the spacing and line breaks; a reason goes through `error_text()`. +- **Tests**: +1; it finds the HAR tab as it was before U-20260924-256. +- **Result / numbers**: 2248 tests in 124 files. +- **Files**: `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-267 · 2026-09-24 · Refactor: the diagram file checks raise from exception_tags · #refactor #i18n #diagram + +- **What**: `DiagramScene._check_is_a_diagram` refused a file that is not a diagram with two English literals (`a diagram file holds an object, not a list`, `a diagram's 'nodes' is a list, not a str`), which `error_text()` cannot find. They are `diagram_not_an_object_error` and `diagram_section_not_a_list_error` in `exception_tags` now, with the same text. +- **Behaviour**: none visible; the Traditional Chinese entries are in the dictionary for the next step. +- **Tests**: `test_error_text.py` finds both again from a message made from them (+2); the diagram tests pass unchanged. +- **Result / numbers**: 708 keys in each language. 2250 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `pybreeze/extend_multi_language/extend_traditional_chinese.py`, `README.md`, `architecture_explore.md` +- **Open items**: showing them in the IDE language (next entry). + +## U-20260924-268 · 2026-09-24 · A file that is not a diagram, and a refused CoT URL, say why in the IDE language · #fix #i18n #diagram #ai + +- **What**: opening a `.diagram.json` that is not a diagram showed `a diagram file holds an object, not a list` whatever the IDE spoke, and the Mermaid import showed its reasons the same way. The CoT review panel showed a URL the SSRF check refused in English too (`Scheme 'ftp' is not allowed. Use http or https.`): its thread emitted `str(error)` as it was, where the request failures beside it already went through `error_text()`. +- **Fix**: the diagram editor's open and Mermaid-import warnings, and `code_review_thread`'s URL refusal, pass the reason through `error_text()`; an OS error still shows its own text. +- **Tests**: `test_error_text.py` +1 (opening a file that holds `[]`, in Traditional Chinese). +- **Result / numbers**: 2251 tests in 124 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py`, `test/test_utils/test_error_text.py`, `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260924-269 · 2026-09-24 · Adopt je_editor 1.0.27 docked-editor contract · #done #cross-project + +- **What**: je_editor 1.0.27 changed `FullEditorWidget.__init__` to `(current_file, encoding, line_ending)` and made its `closeEvent` write back only when the document is modified, in the file's own encoding and line ending (JEDITOR U-20260923-07). Bumped the `je-editor` dependency floor to `>=1.0.27` and adapted the two PyBreeze tests that pinned the old contract: `test_jeditor_contract` now expects the three-parameter signature, and the docked-rename test marks the document modified after `setPlainText` (which resets the modified flag) so the dock's close writes the edit under the new "write only when edited" rule. +- **Result / numbers**: `test/test_utils/` green on the PR CI matrix (3.10–3.14); the dock no longer rewrites an unedited LF file as CRLF on IDE close. +- **Files**: `requirements.txt`, `pyproject.toml`, `dev.toml`, `test/test_utils/test_jeditor_contract.py`, `test/test_utils/test_file_tree_rename.py` +- **Evidence**: closes `progress.md` #47; je_editor `FullEditorWidget.closeEvent` gates on `document().isModified()`. + +## U-20260924-270 · 2026-09-24 · Report a deeply nested regex instead of crashing on CPython 3.10 · #fix #regex + +- **What**: `compile_pattern` caught `RecursionError` for over-nested groups, but on CPython 3.10 a pattern nesting thousands of groups overflows the C stack of `re`'s parser and hard-crashes the interpreter before any catchable error is raised (the test `test_groups_nested_too_deep_are_reported` took the 3.10 CI leg down with an `sre_parse` stack overflow). Added an iterative depth scan (`_group_nesting_too_deep`, cap `MAX_GROUP_NESTING = 100`) that skips escaped parens and character-class contents and raises `RegexTesterException` before the pattern reaches `re.compile`. +- **Result / numbers**: `test/test_utils/test_regex_tester.py` passes on the 3.10 matrix leg; normal patterns still compile, escaped `\(` and `[(]` are not counted. +- **Files**: `pybreeze/utils/regex_tools/regex_tester.py` +- **Evidence**: crash trace `sre_parse.py _parse_sub/_parse` on Python 3.10.11; ruff clean. diff --git a/docs/updates/2026-09.md b/docs/updates/2026-09.md new file mode 100644 index 00000000..716783d0 --- /dev/null +++ b/docs/updates/2026-09.md @@ -0,0 +1,3156 @@ +# 2026-09 update log + +Index and query commands: [README.md](README.md). Continued in [2026-09-b.md](2026-09-b.md): new entries go there. + +--- + +## U-20260922-01 · 2026-09-22 · Adopt progress/architecture/docs-updates rules · #docs #migration + +- **What**: `CLAUDE.md` gained the workspace rule section (commit at every stage; `progress.md` holds open items only; finished work is recorded in `docs/updates/`; `architecture.md` is the short overview). Added `architecture.md` and this `docs/updates/` index. Moved PyBreeze's open items here from JEditor's `PROGRESS.md` and seeded the rest from the workspace inventory of 2026-09-22. +- **Files**: `CLAUDE.md`, `progress.md`, `architecture.md`, `docs/updates/`. +- **Open items**: see `progress.md`. + +## U-20260922-02 · 2026-09-22 · Run windows ignored the interpreter chosen in the IDE · #incident #process-executor + +- **What**: every automation run (the six `call_*` menus, the directory batch runs) and every prthinker review started its child with the working directory's `venv/` / `.venv/`, or whatever `python` was on `PATH`, even after the user picked an interpreter in the Python environment menu. `TaskProcessManager.renew_path()` reads `python_compiler` from the run window it is given, and the run window was always a fresh `CodeWindow` whose `python_compiler` is `None`; the main window's choice (set by JEditor's menu or restored from the saved setting in `startup_setting()`) never reached it. Only the TestPioneer runner, which read the main window directly, used it. +- **Fix**: `process_executor_utils._build_task_process` became the public `build_task_process()` and copies `main_window.python_compiler` onto the run window. prthinker's `_run` now opens its run window through it instead of repeating the same four steps. +- **Result**: a run uses the chosen interpreter; the working-directory search is only the fallback. For prthinker this means a 3.12+ interpreter chosen in the IDE is enough, even while PyBreeze itself runs on 3.11. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/extend/process_executor/prthinker/prthinker_process.py`, `test/test_utils/test_build_task_process.py` (new), `test/test_utils/test_prthinker_process.py`, `architecture_explore.md` §4.1–4.2. +- **Open items**: none. + +## U-20260922-03 · 2026-09-22 · Point project URLs at the current repository · #done #metadata + +- **What**: replaced the misspelled or renamed GitHub owner in the project URLs (`Intergration-Automation-Testing/…`, and where present `JE-Chen/je_editor`, `JE-Chen/Integration-testing-environment`, the `AutoControl`/`AutomationEditor`/`AutomationFile`/`JEditor` repo names) with the current `origin`. Package metadata and clone commands now name `Integration-Automation/PyBreeze`; the sister-project links in the READMEs and the `github_url` of the APITestka, AutoControl, LoadDensity and WebRunner menus now use `Integration-Automation/…` (AutoControl → `AutoControlGUI`, AutomationFile → `FileAutomation`, JEditor → `JEDITOR`). The plugin-guide link in `docs/source/{Eng,Zh}/menu_plugins.rst` now targets `PyBreeze/blob/main/PLUGIN_GUIDE.md` (where it should point in the long run is workspace X-10). Workspace item X-11. Closes `progress.md` #9. +- **Files**: `README.md`, `README/README_zh-CN.md`, `README/README_zh-TW.md`, `dev.toml`, `docs/source/Eng/menu_plugins.rst`, `docs/source/Zh/menu_plugins.rst`, `pybreeze/pybreeze_ui/menu/automation_menu/api_testka_menu/build_api_testka_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/load_density_menu/build_load_density_menu.py`, `pybreeze/pybreeze_ui/menu/automation_menu/web_runner_menu/build_webrunner_menu.py`, `pyproject.toml`. +- **Open items**: none. + +## U-20260922-04 · 2026-09-22 · Run window keeps the output's indentation · #done #process-executor + +- **What**: `progress.md` #2. The reader threads dropped whitespace-only lines, and `pump_message_queue()`, `FileRunnerProcess._pull_text()` / `_drain_queues()` and both drain methods called `.strip()` on every line, so indented output (code, JSON, tracebacks) arrived flush left and blank lines vanished. +- **Fix**: lines now travel exactly as read. `CodeWindow.append_output()` turns `\r\n` and a lone `\r` into line breaks and inserts nothing else, so a line longer than the read buffer, which arrives in pieces, joins up again instead of being broken at each piece. Status messages the window writes itself (`Task exit with code …`, the missing-interpreter error) pass `own_line=True`, which starts them on a fresh line when the program left one unfinished. +- **Result / numbers**: `python -m json.tool` output shows its 4- and 8-space indentation in the run window; before the change the same test showed `def f():\nreturn 1\nwarned`. Unit tests 985 → 1006 across this entry and U-20260922-02/-05. +- **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_queue_pump.py`, `test/test_utils/test_code_window.py`, `test/test_utils/test_run_output.py` (new, runs real child processes through the whole pipeline). +- **Open items**: none. + +## U-20260922-05 · 2026-09-22 · One output pipeline for every run window · #done #process-executor + +- **What**: `progress.md` #3. `TaskProcessManager`, `TestPioneerProcess` and `FileRunnerProcess` each carried their own reader loop, drain loops and a verbatim copy of `_append_text`; the TestPioneer runner also resolved its interpreter in its constructor with `check_and_choose_venv()` and let `JEditorExecException` escape the menu callback. +- **Fix**: one pipeline, used by both remaining executors. `queue_pump.read_stream_into_queue()` is the reader thread body; `pump_message_queue(..., max_messages=None)` is the final drain; `CodeWindow.append_output()` replaces the three `_append_text` copies. `TestPioneerProcess` (≈180 lines) is gone: `init_and_start_test_pioneer_process()` opens a run window with `build_task_process()` and calls `start_module_process("test_pioneer", ["-e", ])`, so a missing interpreter is written to the run window like everywhere else. `FileRunnerProcess._pull_text()` now drains at most 256 messages per tick like the other executor instead of emptying the queue in one tick. +- **Also fixed**: `_append_text` wrote at the widget's own cursor, which follows the user's clicks and selections. Selecting text in a run window while the program was still printing replaced the selection with the new output (reproduced offscreen: `first\nsecond\n` + select `first` + append `third\n` gave `third\n\nsecond\n`). `append_output()` always writes at the end of the document. +- **Files**: `pybreeze/extend/process_executor/{queue_pump,python_task_process_manager,file_runner_process}.py`, `pybreeze/extend/process_executor/test_pioneer/test_pioneer_process_manager.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, tests as in U-20260922-04, `architecture_explore.md` §4.1, §4.3–4.5, §18–19, `architecture.md` §4 and §6, `CLAUDE.md` tree. The matching `README.md` tree line went in early, with 2622645. +- **Open items**: none. + +## U-20260922-06 · 2026-09-22 · Run window follows its output · #incident #process-executor + +- **What**: a run window never scrolled on its own. Output is inserted through a text cursor, and inserting text never moves the view, so a long run (a test suite, a load test) kept showing its first screen and the user had to drag the scroll bar to see the latest lines, including the final `Task exit with code …`. +- **Fix**: `CodeWindow.append_output()` checks whether the view is at the bottom before it writes, and if so moves it to the new bottom afterwards. A user who has scrolled up to read is left where they are. +- **Result / numbers**: offscreen, 300 appended lines ended at scroll value 3848 of 3848 (before: 0 of 3848); with the view moved to 100, 50 more lines left it at 100. +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `test/test_utils/test_code_window.py`, `architecture_explore.md` §4.5. +- **Open items**: none. + +## U-20260922-07 · 2026-09-22 · Install the checkout, not the published build · #done #dependencies + +- **What**: `progress.md` #4. `requirements.txt` listed `pybreeze` and `dev_requirements.txt` listed `pybreeze_dev`, so both CI workflows installed the published PyPI build next to the checkout. `dev_requirements.txt` also got `je-editor`, `automation-file`, `paramiko` and `jupyterlab` only through that published package, so it named only part of what the IDE needs. It listed `PySide6` twice, unpinned, and left out the pytest tooling that `CLAUDE.md` § Development tells a developer to run after installing it. +- **Fix**: `requirements.txt` drops `pybreeze` and gets a header saying why (the same wording FrontEngine uses). `dev_requirements.txt` is now `-r requirements.txt` plus pytest, pytest-cov, hypothesis, ruff, sphinx, sphinx-rtd-theme, build, twine and auto-py-to-exe, so runtime and dev can no longer drift apart. Both files start with a PEP 263 `# -*- coding: utf-8 -*-` line: their comments are bilingual, and pip before 25.0 (pypa/pip#12771) tried the locale encoding (cp1252 on a Windows runner) before UTF-8 unless the file declares one. The README "From source" steps (English, zh-TW, zh-CN) cloned `PyBreeze.git` and then ran `cd AutomationEditor`; they now `cd PyBreeze`. +- **Result / numbers**: `pip install --dry-run --ignore-installed -r dev_requirements.txt` resolves 262 packages from PyPI with no `pybreeze*` among them. The same resolution showed why #7 matters: with `PySide6==6.11.0`, pip backtracks je_editor from 1.0.25 (which pins 6.11.1) to 1.0.24. +- **Files**: `requirements.txt`, `dev_requirements.txt`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`. +- **Open items**: `progress.md` #7 (PySide6 pin). + +## U-20260922-08 · 2026-09-22 · PLUGIN_GUIDE.md points to JEditor's guide · #done #docs + +- **What**: `PLUGIN_GUIDE.md` was a word-for-word copy of JEditor's, and both copies pointed at a nonexistent `exe/jeditor_plugins/`. PyBreeze uses JEditor's plugin system unchanged (`EditorMain.__init__` calls `load_external_plugins()`), so the guide now lives only in JEditor (fixed there, JEDITOR U-20260922-04) and this file is a short bilingual pointer to it and to IDE_Plugins. The *Creating Plugins* link in `docs/source/{Eng,Zh}/menu_plugins.rst` goes straight to JEditor's guide, and known issue 6 in `architecture_explore.md` §19 is gone. Workspace item X-10. +- **Files**: `PLUGIN_GUIDE.md`, `docs/source/Eng/menu_plugins.rst`, `docs/source/Zh/menu_plugins.rst`, `architecture_explore.md`. +- **Open items**: none. + +## U-20260922-09 · 2026-09-22 · Describe the release flow CI actually runs · #done #ci + +- **What**: `progress.md` #5. `CLAUDE.md` § Branching & CI said `dev` publishes `pybreeze_dev` and that both toml versions are bumped together; `architecture.md` §1, §2 and §7 repeated it. In fact `dev.yml` has only the `unit-tests` and `sonarcloud` jobs, the only publisher is `stable.yml`'s `publish` job (on a push to `main` it bumps the patch version in `pyproject.toml` alone, uploads `pybreeze`, then commits and tags the bump), and PyPI's `pybreeze_dev` is still at the 1.0.14 in `dev.toml`, while `pybreeze` is at 1.0.30. The workspace rule is also that nobody edits a version by hand. +- **Fix**: those four places now describe what CI does. `dev.toml` is described as unpublished packaging whose `dependencies` stay identical to `pyproject.toml`'s. Whether to publish it or delete it stays with the owner (workspace X-13). +- **Files**: `CLAUDE.md`, `architecture.md`. +- **Evidence**: `.github/workflows/stable.yml` `publish` job; `pip index versions pybreeze_dev` → 1.0.14; `pip index versions pybreeze` → 1.0.30. +- **Open items**: workspace X-13 (decision). + +## U-20260922-10 · 2026-09-22 · Pin PySide6 6.11.1 like the published je_editor · #done #dependencies + +- **What**: `progress.md` #7. PyBreeze pinned `PySide6==6.11.0` while the published je_editor 1.0.25 and frontengine 1.0.77 pin 6.11.1, so the latest releases could not be installed together. pip got around the conflict by backtracking je_editor to 1.0.24 (the last one pinning 6.11.0), which meant CI never ran against the current JEditor. +- **Fix**: the pin is `PySide6==6.11.1` in `pyproject.toml`, `dev.toml` and `requirements.txt`, and the version the three READMEs quote says the same. +- **Result / numbers**: a fresh Python 3.13 venv built from `dev_requirements.txt` alone resolves je_editor 1.0.25, PySide6 6.11.1 and frontengine 1.0.77, with no `pybreeze*`. In that venv, the unit tests at the preceding commit `fb6cc20` (this change touches no code) pass, 1008 of 1008, and both startup tests (`start_automation_test.py`, `extend_automation_test.py`) exit 0. +- **Files**: `pyproject.toml`, `dev.toml`, `requirements.txt`, `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture.md` §6. +- **Open items**: PySide6 6.11.2 is on PyPI and dependabot has proposed it to JEditor and FrontEngine. PyBreeze should move when the published je_editor does (workspace X-1). + +## U-20260922-11 · 2026-09-22 · IDE crashed on start with a non-English language saved · #done #i18n + +- **What**: `progress.md` #6, which only suspected that labels "may be missing". Running it showed something worse: with Traditional Chinese (or Japanese, or Simplified Chinese) saved as the language, PyBreeze did not start. `EditorMain.__init__` calls `language_wrapper.reset_language()` with the saved language, and for anything but English JEditor serves a merged copy `{**english, **chosen}` built at that moment. PyBreeze added its 571 strings only after `super().__init__()` returned, so the copy lacked all of them. `add_menu_to_menubar()` then passed `None` to `QMenuBar.addMenu()`, and the process died with an access violation (exit 3221225477 / 0xC0000005) inside `build_menubar.py:38`. A first launch was unaffected, because JEditor's default settings already say English. But anyone who chose 繁體中文 in the language menu could not start the IDE again until they edited `.jeditor/user_setting.json` by hand. A second, smaller defect: Japanese and Simplified Chinese carry JEditor's own `application_name`, so their window was titled "JEditor". +- **Fix**: `PyBreezeMainWindow.__init__` calls `update_language_dict()` before `super().__init__()`. `update_language_dict()` also writes PyBreeze's `application_name` into every dict in `language_wrapper.choose_language_dict`. It is the only JEditor key PyBreeze replaces rather than adds. +- **Result / numbers**: started for real (child process, offscreen and on the Windows platform), all four languages construct the window with every menu titled; before, Traditional Chinese and Japanese exited 3221225477. Unit tests 1008 → 1012. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `pybreeze/extend_multi_language/update_language_dict.py`, `test/test_utils/test_startup_language.py` (new: starts the IDE in a child with a saved language, since a crash cannot be caught in-process), `test/test_utils/test_language_parity.py` (every registered language resolves every key the code uses; the window is "PyBreeze" in each), `architecture.md` §4 and §6, `architecture_explore.md` §3, §13, §18. +- **Open items**: none. + +## U-20260922-12 · 2026-09-22 · Run with rewrote the file in UTF-8 and CRLF · #incident #plugins + +- **What**: before a "Run with…" or Plugins-menu run, PyBreeze saved a tab that already had a file with JEditor's plain `write_file()`. That function always opens the file as UTF-8 in text mode, so every such run rewrote the file on disk. On Windows an LF file came back CRLF; a Big5, UTF-16 or any non-UTF-8 file JEditor had opened in its own encoding was re-encoded as UTF-8 (reproduced: Big5 `中文\n第二行\n` became the UTF-8 bytes `e4 b8 ad e6 96 87 0d 0a …`). The write also skipped `mark_ignore_next_file_change()`, so the tab's file watcher took PyBreeze's own save for an outside edit and asked whether to reload, while the program was starting. It left the tab's unsaved `*` marker in place, too. The same save-and-run block was written out twice, once in each menu. +- **Fix**: one helper, `build_run_with_menu.save_current_file_for_run()`, writes the way JEditor's own saves do: `write_file_with_encoding()` with the tab's `file_encoding` and `line_ending`, `mark_ignore_next_file_change()` first and `mark_saved()` after. A tab without a file still goes through JEditor's Save As. The Plugins menu's run callback now calls `run_current_file_with()` instead of repeating the save, the suffix check and the run. +- **Result / numbers**: the Big5/CRLF test file keeps its exact bytes. `build_plugin_menu.py` drops three JEditor imports (`EditorWidget`, `choose_file_get_save_file_path`, `write_file`) and 48 net lines. +- **Files**: `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_plugin_menu.py`, `test/test_utils/test_plugin_menu.py`, `architecture.md` §4, `architecture_explore.md` §5.4, §18. +- **Open items**: none. + +## U-20260922-13 · 2026-09-22 · Write down and test what PyBreeze takes from JEditor · #done #jeditor + +- **What**: `progress.md` #8. PyBreeze reached into JEditor through module paths je_editor never promised to keep, and nothing said which ones or checked them. Taking stock showed three kinds: names je_editor already exports but PyBreeze imported by their internal path (`EditorWidget` twice, `JEditorExecException`, `get_all_plugin_run_configs`, `get_all_plugin_metadata`); names U-20260922-12 made unnecessary (`write_file`, and the second copy of `choose_file_get_save_file_path`); and the ones PyBreeze truly needs. +- **Fix**: the exported names now come from `je_editor`'s top level. The remaining eight internal names are listed in `architecture.md` §6 with their modules and users, with the `EditorWidget` members and `language_wrapper` behaviour PyBreeze relies on: `PluginBrowserWidget`, `DestroyDock`, `check_and_choose_venv`, `choose_file_get_save_file_path`, `write_file_with_encoding`, `DEFAULT_ENCODING`, `LINE_ENDING_LF` and `actually_color_dict`. The new `test/test_utils/test_jeditor_contract.py` pins each import path and the shape PyBreeze calls it with (constructor and function parameters, the colour keys, the save members, and that English and Traditional Chinese are served from the exported dict objects PyBreeze edits in place). It also fails when the code imports an internal name that is not on the list, so the list cannot go stale. Two tests that patched `EditorWidget` at its internal path now patch `je_editor.EditorWidget`. +- **Result / numbers**: internal `je_editor` import lines in `pybreeze/` went from 19, naming 10 distinct names (at `ec32f8e`, before this day's work), to 8, naming exactly the 8 listed ones. The contract test passes against je_editor 1.0.22 (local venv), 1.0.25 (current PyPI) and the JEDITOR working tree at `b1f6261`. Unit tests 1017 → 1047. +- **Files**: `pybreeze/extend/process_executor/python_task_process_manager.py`, `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `pybreeze/pybreeze_ui/tools_gui/output_actions.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/{build_plugin_menu,build_run_with_menu}.py`, `test/test_utils/test_jeditor_contract.py` (new), `test/test_utils/{test_output_actions,test_curl_import_gui,test_run_output,test_task_manager_venv}.py`, `architecture.md` §6, `architecture_explore.md` §18. +- **Open items**: having je_editor export the eight names is workspace X-17 (JEditor side). + +## U-20260922-14 · 2026-09-22 · Extra prthinker arguments lost their Windows backslashes · #incident #prthinker + +- **What**: the prthinker settings' free-form "extra arguments" were split with `shlex.split()`, which follows POSIX rules: a backslash escapes the next character. On Windows, where a backslash separates path parts, `--output-dir C:\reviews\out` reached prthinker as `C:reviewsout`. Found while running a real review for `progress.md` #1. +- **Fix**: `prthinker_setting.split_arguments(text, backslash_escapes=...)` splits with `shlex.shlex` in POSIX mode (quotes still group, and are removed), and turns backslash escaping off where `os.sep` is a backslash. Elsewhere it behaves as before. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §14, §18. +- **Open items**: none. + +## U-20260922-15 · 2026-09-22 · Dependency audit of what CI installs · #snapshot #dependencies + +- **What**: a CVE audit of what CI installs now, as opposed to the long-lived local `.venv` (which still had je_editor 1.0.22 and PySide6 6.11.0). A fresh Python 3.13 venv was built from `dev_requirements.txt` alone after U-20260922-07 and -10, and `pip-audit` was run in it. +- **Result / numbers**: 276 packages audited, 0 with a known vulnerability. Resolved versions of the direct dependencies that an audit on 2026-06-27 had flagged: jupyterlab 4.6.4, paramiko 5.0.0. The rest of the stack: je_editor 1.0.25, PySide6 6.11.1, frontengine 1.0.77. +- **Evidence**: `python -m pip_audit --progress-spinner off --format json` in that venv; the same venv passed the unit tests (1008) and both startup tests (U-20260922-10). +- **Open items**: dependencies other than PySide6 are still unpinned, despite `CLAUDE.md` § Security › Dependencies. Pinning the je_* family changes how releases are cut, so it is the owner's call (not added to `progress.md`). + +## U-20260922-16 · 2026-09-22 · Contract test for the prthinker command line · #done #prthinker #ci + +- **What**: the contract-test half of workspace X-8. `test/test_utils/test_prthinker_contract.py` runs the real `python -m prthinker` with the arguments (`review_file_arguments`, `review_pr_arguments`) and the environment (`utf8_subprocess_env` plus `environment_for`) PyBreeze builds, with the backend and the forge pointed at a closed local port. Reaching `ConnectError` means prthinker accepted every argument and variable; a rename on either side stops it earlier with "unrecognized arguments", "is required", "is not a valid" or "invalid choice". A fourth case checks that every backend PyBreeze offers (`BACKENDS`) is a `prthinker.config.BackendKind` value. The tests skip unless prthinker is importable by the running interpreter or `PYBREEZE_PRTHINKER_PYTHON` names one that has it. +- **CI**: `dev.yml` and `stable.yml` install `prthinker[runner]` from the GitHub `main` archive on the Python 3.12 leg only (prthinker is not on PyPI and needs 3.12+), before the unit tests. +- **Result / numbers**: 33 passed for the contract test plus `test_prthinker_setting.py` with `PYBREEZE_PRTHINKER_PYTHON` set to the prthinker checkout's `.venv` (Python 3.14.4, `dev` at `14a3d42`). Full `test/test_utils/` in the local 3.11 venv: 1050 passed, 4 skipped (the contract tests). `ruff check` on the new file is clean. +- **Files**: `test/test_utils/test_prthinker_contract.py` (new), `.github/workflows/dev.yml`, `.github/workflows/stable.yml`. +- **Open items**: `progress.md` #1 (a real review) and the rest of workspace X-8. + +## U-20260922-17 · 2026-09-22 · Automation submenus came up empty · #incident #menus + +- **What**: every `Run`, `HELP` and `Project` submenu of the six factory-built automation menus (APITestka, AutoControl, FileAutomation, LoadDensity, MailThunder, WebRunner) was empty, and so was AutoControl's `Record` submenu; the `GUI` entries were gone as well. `build_automation_menu()` and `set_autocontrol_menu()` built each `QAction` with no parent and kept it only in a local variable. A menu does not own the actions added to it, so each one was deleted when the builder returned and its entry disappeared. Found by walking the started IDE's menu bar while preparing the real prthinker review for `progress.md` #1. +- **Fix**: each of those actions takes the menu it is added to as its parent, the way the plugin menus already did. `CLAUDE.md` § Conventions and `architecture.md` §7 now allow either way of keeping an action alive (stored on the main window, or parented to its menu). +- **Tests**: `test_automation_menu_factory.py` builds each kind of entry, collects garbage, and triggers it. `test_started_menus.py` starts the real IDE in a child, collects garbage, and fails on any empty submenu of the whole menu bar (JEditor's two font menus excepted: the offscreen platform has no fonts). The child start is shared with `test_startup_language.py` through the new `test/test_utils/started_window.py`. All four new tests fail with the fix reverted. +- **Result / numbers**: unit tests 1050 → 1054 passed, 4 skipped (the prthinker contract tests, local Python 3.11 venv). `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py`, `test/test_utils/{test_automation_menu_factory,test_started_menus,started_window,test_startup_language}.py`, `CLAUDE.md`, `architecture.md` §7, `architecture_explore.md` §5.1, §5.3, §18. +- **Open items**: none. + +## U-20260922-18 · 2026-09-22 · Run windows lost their output and exit line · #incident #process-executor + +- **What**: a run window could stop showing a child's output part-way, or show none of it, and never printed `Task exit with code …` / `[Process exited with code …]`. Every caller dropped its executor: `start_process()`, `build_process_from_file()`, prthinker's `_run()` and TestPioneer kept the `TaskProcessManager` from `build_task_process()` in a local variable, and `run_current_file_with()` did the same with its `FileRunnerProcess`. The executor's QTimer connection to its pump does not keep it alive, so once its reader threads ended nothing held it; the next garbage collection removed it and the pump stopped. Probe: `python -m this` through `build_task_process()` came back empty on one run and without the exit line on the next, and whole every time the manager was held. Found when the first PyBreeze-driven prthinker review (`progress.md` #1) showed an empty run window. +- **Fix**: the run window holds its executor. `CodeWindow` has a `runner` attribute; `build_task_process()` and `run_current_file_with()` set it, so the executor lives as long as its window, which `current_run_code_window` keeps. `architecture.md` §7 states the rule. +- **Tests**: two tests in `test_run_output.py` start a real child without holding its executor and collect garbage on every event-loop check, one through `build_task_process()` and one through `run_current_file_with()`. Both failed (no exit line within 30 s) before the fix. The existing tests held their executor, which is why they never saw it. +- **Result / numbers**: unit tests 1054 → 1056 passed, 4 skipped (local Python 3.11 venv). After the fix, the PyBreeze-driven prthinker review showed its whole traceback and `Task exit with code 1` (the review itself failed: see the next prthinker entry). +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `test/test_utils/test_run_output.py`, `architecture.md` §7, `architecture_explore.md` §4.2, §4.4, §16, §18. +- **Open items**: none. + +## U-20260923-01 · 2026-09-23 · First real prthinker review run from PyBreeze · #done #prthinker + +- **What**: `progress.md` #1 (workspace X-8). The prthinker integration had never run a real review, and the reason given, that PyBreeze's venv is Python 3.11 while prthinker needs 3.12 or newer, did not hold: the review and the install both run with the interpreter chosen in the IDE, not PyBreeze's own. The run was driven through PyBreeze's own actions, headless: PyBreeze from a fresh Python 3.13 venv built from `requirements.txt` (je_editor 1.0.25, PySide6 6.11.1) with this checkout on `PYTHONPATH`; a second, empty 3.13 venv set as the IDE's interpreter; `Install ▸ Automation ▸ Install prthinker` pip-installed `[runner]` into it (prthinker `dev` at `14a3d42`); `Code Review ▸ Review the current file` then reviewed a copy of `prthinker_process.py`. Settings: backend `claude-cli` (the machine's `claude` 2.1.278, so no API key), extra arguments `--claude-cli-model haiku --output-dir C:\...\out`, everything else default. +- **Found on the way**: U-20260922-17 (empty automation submenus) and U-20260922-18 (run windows losing their output). And with the defaults the review could not run at all: prthinker retrieves rules locally unless told otherwise, and that index (`codes/`) lives only in its repository; the package excludes it, so every review from an installed prthinker died on `ModuleNotFoundError: No module named 'codes'`. The contract test (U-20260922-16) had passed `--no-rag` by hand for that reason, which hid it. +- **Fix**: a `rag` setting, `off` (default) or `remote` (`RAG_MODES`), always sent: `off` as `PRTHINKER_RAG_ENABLED=false`, `remote` as `PRTHINKER_REMOTE_RAG=true`, which asks the server's `/rag`. An unknown stored value counts as `off`. The settings dialog picks it from a list; choice fields now come from one table (`CHOICE_FIELDS`) instead of an `if` per field. The contract test no longer passes `--no-rag`, so the default is what it checks, and a new case checks `remote`. Renaming either variable fails three contract cases (checked). +- **Result / numbers**: review finished in 136 s with `Task exit with code 0`: five steps (`first_summary`, `first_code_review`, `linter`, `code_smell`, `total_summary`), their log lines streaming into the run window, the summary printed at the end, and five `*_result.md` files written to the backslashed `--output-dir`. Unit tests 1056 → 1063 passed, 5 skipped (local 3.11 venv, where the contract tests skip); contract and settings tests 40 passed against the prthinker the Install menu put in the 3.13 venv. `ruff check` clean. `images/prthinker_setting.png` re-rendered from the dialog. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_prthinker_setting,test_prthinker_setting_dialog,test_prthinker_contract}.py`, `images/prthinker_setting.png`, `README.md`, `architecture_explore.md` §14, §18, `progress.md`. +- **Open items**: none here. The prthinker side of workspace X-8 is closed in the root `progress.md`. + +## U-20260923-02 · 2026-09-23 · prthinker ignored the model name for seven backends · #incident #prthinker + +- **What**: the settings' "Model name" was always sent as `PRTHINKER_MODEL_NAME`, which only prthinker's `local` backend reads (`remote` just reports it). `openai`, `anthropic`, `gemini`, `cohere`, `mistral`, `claude-cli` and `codex-cli` each read their own `PRTHINKER__MODEL`, so for seven of the nine backends on offer the model typed in was dropped and prthinker's default used instead (for `anthropic`, `claude-opus-4-7`). The first real review (U-20260923-01) had to pass `--claude-cli-model` as an extra argument for this reason. +- **Fix**: `MODEL_ENVIRONMENT` maps each backend to the variable it reads, and `environment_for()` sends the model name only there. `model_name` left `SETTING_ENVIRONMENT`. +- **Tests**: `test_prthinker_setting.py` pins the variable per backend, that exactly one model variable is sent, and that every backend in `BACKENDS` has one. The contract test gains a case per backend: it builds prthinker's own review configuration from PyBreeze's environment alone (`prthinker.cli._build_parser`, `_build_config`, both in prthinker's `__all__`) and checks the chosen backend carries the model. Against the prthinker the Install menu put in a 3.13 venv: 7 of 9 failed before the fix, all passed after. +- **Result / numbers**: unit tests 1063 → 1074 passed; 14 skipped (the prthinker contract tests, local 3.11 venv); contract and settings tests 75 passed against the installed prthinker. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/{test_prthinker_setting,test_prthinker_contract}.py`, `README.md`, `architecture.md` §6 (now lists every variable PyBreeze sends), `architecture_explore.md` §14, §18. +- **Open items**: none. + +## U-20260923-03 · 2026-09-23 · Automation menus described by one AutomationMenu · #done #refactor #menus + +- **What**: a refactor, no change in behaviour. `build_automation_menu()` took 13 parameters (the quality gate is 7) and was 83 lines long. It now takes the main window and one `AutomationMenu`, a frozen dataclass holding the label key, a tuple of `RunAction` (label key, callback), a tuple of `HelpLink` (URL, label key, tab label key), the project entry and the GUI entry. Each submenu is built by its own helper (`_add_run_menu`, `_add_help_menu`, `_add_project_menu`, `_add_gui_action`). The six `build_*_menu.py` callers each pass one `AutomationMenu(...)` instead of keyword arguments and dicts. +- **Guarded by**: `test_automation_menu_factory.py` (moved to the new call, same assertions) and `test_started_menus.py`. The whole started menu bar, walked after garbage collection, is identical before and after (413 lines, compared with `diff`). +- **Result / numbers**: `ruff check` with the gate's limits (`PLR0913` max 7 arguments, `C901` max 15) now reports one function in `pybreeze/`, `diagram_items.py:228` (9 arguments), down from three before U-20260922-17 and this entry. Unit tests 1074 passed, 14 skipped. +- **Files**: `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py`, the six `pybreeze/pybreeze_ui/menu/automation_menu/*/build_*_menu.py`, `test/test_utils/test_automation_menu_factory.py`, `architecture.md` §5, `architecture_explore.md` §5.1. +- **Open items**: none. + +## U-20260923-04 · 2026-09-23 · DiagramNode takes its colours and font size as one NodeStyle · #done #refactor #diagram + +- **What**: a refactor, no change in behaviour. `DiagramNode.__init__` took nine parameters, past the quality gate's seven: position, size, text, shape, and three that only set how the node is drawn. Those three (`fill_color`, `border_color`, `font_size`) now travel as one frozen `NodeStyle` (`style=None` means the defaults). `from_dict()` builds the `NodeStyle` from the same keys, so the `.diagram.json` format is unchanged, including the old `color` key still read as the fill. +- **Guarded by**: `test_diagram_serialization.py` (round trip, corrupt colours, zero sizes), the Mermaid import tests, and the rest of the diagram tests; one test moved to `style=NodeStyle(fill_color=...)`. +- **Result / numbers**: `ruff check pybreeze/` with the gate's limits (`PLR0913` max 7 arguments, `C901` max 15) now reports nothing. Unit tests 1074 passed, 14 skipped. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §7 (line counts re-measured). +- **Open items**: none. + +## U-20260923-05 · 2026-09-23 · parse_mermaid back under the cognitive complexity cap · #done #refactor #diagram + +- **What**: a refactor, no change in behaviour. `parse_mermaid()` had a cognitive complexity of 17, the only function in `pybreeze/` past the gate's 15 (measured with `complexipy`, which computes the same metric as SonarQube). Line parsing moved to `_parse_lines()` and building the diagram dict to `_to_diagram_dict()`; the nested width helper became `_node_width()`, and its bare numbers became `_NODE_MIN_W`, `_NODE_MAX_W`, `_CHAR_W`, `_TEXT_PADDING`, with the height taken from the existing `_NODE_H` (the same 60). +- **Guarded by**: the Mermaid and diagram tests, including the Hypothesis ones; and a differential run of the old and the new `parse_mermaid()` over 3,010 inputs (10 hand-written flowcharts and 3,000 random strings from arrows, brackets, `;`, `%%` and direction words, 2,705 of which produce nodes): identical output, or the same exception type, for every one. +- **Result / numbers**: `complexipy pybreeze --max-complexity-allowed 15` reports nothing; the highest left is 14 (`AICodeReviewClient.send_request`, `_assign_layers`). Unit tests 1074 passed, 14 skipped. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `architecture_explore.md` §7 (line counts re-measured). +- **Open items**: none. + +## U-20260923-06 · 2026-09-23 · Runs outlived the IDE · #incident #process-executor + +- **What**: a run's child kept running after the IDE closed. The children are separate processes started with `CREATE_NO_WINDOW`, nothing ended them, and `start_editor()` leaves through `os._exit()`, so a long run (a load test, a web run, anything waiting) went on with no window and no output until found in a task manager. `PyBreezeMainWindow.closeEvent()` only closed the run windows. Probe: `python -m http.server` started through `build_task_process()`, then the window closed and the process left with `os._exit(0)`, as `start_editor()` does; two seconds later the child's PID was still listed. +- **Fix**: `TaskProcessManager.stop()` and `FileRunnerProcess.stop()` terminate the child if it is still running (the child only; anything it started is its own business); `CodeWindow.stop_runner()` calls the window's executor; `closeEvent()` stops every run window's run before closing it. JEditor's own `closeEvent()` never cancels a close, so the IDE is really going away when this runs. `current_run_code_window` is now typed `list[CodeWindow]`. +- **Tests**: `test_closing_the_ide.py` starts the real IDE in a child, starts a 60-second run, closes the window, and waits up to 15 s for the run to end. It failed before the fix, once it pinned the interpreter: left unchosen, the run had picked the `python` on `PATH`, the Microsoft Store stub, which exits at once and made the test pass for the wrong reason. `test_run_output.py` gains four: stopping a Python run and a plugin run (each then reports its exit), stopping a finished run changes nothing, and a window that never ran anything can be stopped. +- **Result / numbers**: unit tests 1074 → 1079 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/{python_task_process_manager,file_runner_process}.py`, `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_run_output,test_closing_the_ide}.py`, `architecture.md` §7, `architecture_explore.md` §4.1, §4.4, §18, `progress.md` (#2, #3). +- **Open items**: `progress.md` #2 (whether closing one run window should stop its run) and #3 (a test that failed once under load, name not captured). + +## U-20260923-07 · 2026-09-23 · A plugin run that read input hung for good · #incident #process-executor + +- **What**: running a file through a plugin run config (`Run with…` / the Plugins menu) gave the child `stdin=subprocess.PIPE`, and nothing ever wrote to that pipe or closed it. A program that read input waited on it for good: `input('name? ')` in a Python file was still running after 8 s with nothing in the run window but the command line, because the prompt has no newline and a non-terminal stdout is block-buffered. The only way out was to stop it — and before U-20260923-06, closing the IDE did not even do that. +- **Fix**: `stdin=subprocess.DEVNULL`. A read now ends at once: the program gets `EOFError` (or its language's end-of-input) and the run window shows it and the exit code. The Python executor is unchanged — it passes no `stdin`, so its children inherit the IDE's, which is not a pipe nobody writes to. +- **Tests**: `test_run_output.py` runs a one-line script that calls `input()` and expects `EOFError` and `[Process exited with code 1]`. Before the fix it timed out after the harness's 30 s. +- **Result / numbers**: unit tests 1079 → 1080 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/file_runner_process.py`, `test/test_utils/test_run_output.py`, `architecture_explore.md` §4.4, §18. +- **Open items**: none. + +## U-20260923-08 · 2026-09-23 · Install did nothing when the tab in front was not an editor · #incident #install + +- **What**: every entry of `Install ▸ Automation` (and `Install ▸ Tools`) did nothing at all when the tab in front was not an editor tab — a tool tab, the diagram editor, JupyterLab, a run window. `install_package()` ran its whole body inside `if isinstance(widget, EditorWidget):` with no `else`: no install, no message, nothing in the log. The install needs an editor tab because JEditor's `ShellManager` writes its output into that tab's shell panel. +- **Fix**: `install_package()` returns whether it started; with no editor tab in front it logs and shows a message box naming what to do (`install_need_editor_tab_message`, in both languages). The function also has a docstring saying which interpreter pip runs with, which was the other undocumented part. +- **Tests**: `test/test_utils/test_install_menu.py` (new) covers the command pip is given with and without an interpreter chosen in the IDE, a source-folder target with extras passing through unchanged, and the non-editor tab now being reported rather than ignored. The first three pass against the old code too — they record what it already did. +- **Result / numbers**: unit tests 1080 → 1084 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/install_menu/install_utils.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_install_menu.py` (new), `architecture_explore.md` §5.5, §18. +- **Open items**: none. + +## U-20260923-09 · 2026-09-23 · A run with no script tab handed the package nothing · #incident #process-executor + +- **What**: starting an automation run (`Automation ▸ ▸ Run ▸ …`) with something other than an editor tab in front sent the package no script at all. `build_process()` fell through to `test_format_code = exec_str`, which is `None` there, and `start_test_process()` turned it into the literal `null` on the command line. The run window then showed the package's own confusion: `python -m je_api_testka --execute_str null` answers `TypeError('the JSON object must be str, bytes or bytearray, not NoneType')`. +- **Fix**: `build_process()` looks for the tab only when it needs a script, and with no editor tab in front it opens a run window saying the run takes the script from the editor tab in front (`report_no_script_tab()`), the same way a missing interpreter is reported there rather than thrown at the menu. +- **Tests**: `test_build_task_process.py` gains a non-editor tab that gets the message and starts nothing, and a run given its script outright that still runs with no tab at all. +- **Result / numbers**: unit tests 1084 → 1086 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py`, `test/test_utils/test_build_task_process.py`, `architecture_explore.md` §4.2, §18. +- **Open items**: none. + +## U-20260923-10 · 2026-09-23 · A diagram that failed to load took the open one with it · #incident #diagram + +- **What**: opening a `.diagram.json` that was not a diagram, or that held one bad connection, emptied the canvas and then stopped half-way — and the editor keeps saving to the file it opened last, so the next `Ctrl+S` wrote the wreckage over the user's own diagram. `load_from_dict()` called `_clear_items()` as its first statement, before anything in the data was looked at, and `_load_connections()` had no per-entry guard while nodes and images did. Measured on a scene holding two connected nodes: a top-level JSON list left 0 items (`AttributeError: 'list' object has no attribute 'get'`), `{"source": []}` left the nodes but no connection (`TypeError: unhashable type: 'list'`), `{"style": []}` and a string in place of an object the same, and a node with an unknown `shape` was dropped silently (`KeyError`), leaving the canvas empty with no message at all. +- **Fix**: `load_from_dict()` checks the shape of the data first (`_check_is_a_diagram()`: an object, and each of `nodes` / `connections` / `images` a list) and raises `ValueError` before touching the canvas. Each connection is built in its own `try` like nodes and images, an id that cannot be a dictionary key costs only that node's connections, and an unknown `shape` falls back to `RECTANGLE` the way an unknown connection `style` already fell back to `SOLID`. +- **Also from the same file**: sizes and pens are no longer taken on trust. `MAX_ITEM_SIZE = 10000` caps a node's or an image's width and height (`{"w": 40000, "h": 40000}` with a source asked `QPixmap.scaled` for a ~6 GB allocation on the UI thread; `1e30` overflowed the int PySide6 marshals), `_clamped_line_width()` puts a connection's pen back inside 0.5–10 wherever it comes from (`1e9` made `itemsBoundingRect()` astronomical, which left zoom-to-fit stuck and the canvas blank), and `_safe_color()` now falls back for anything that is not a string (`QColor(5)` is a valid near-black colour, `QColor([])` raises). +- **And saving**: `_write_json()` writes `.saving` beside the target and `os.replace()`s it into place, so a failure part-way through costs the new save and not the last good file; the half-written file is removed. +- **Tests**: `test_diagram_serialization.py` gains a class for loads that cannot work (four payloads that are not diagrams, each leaving the canvas as it was; three bad connections that cost only themselves; two odd pens and a non-string colour that still load; an unknown shape; an unusable id) and one for clamped sizes and pens. `test_diagram_editor_widget.py` (new) builds the real editor widget headless: a save whose replace fails leaves the previous file and no leftovers, a save writes the diagram, and opening a file that is not a diagram tells the user, leaves the canvas, and leaves the open file safe to save to. Confirmed the last one fails with the check removed. +- **Result / numbers**: unit tests 1086 → 1106 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items,diagram_editor_widget}.py`, `test/test_utils/{test_diagram_serialization,test_diagram_editor_widget}.py`, `architecture_explore.md` §7, §18. +- **Open items**: from the same audit, still open: image sources from a saved diagram are handed to `Path.is_file()` before the extension is checked (a UNC path makes Windows authenticate to whatever host it names), image downloads run on the UI thread and are repeated on every undo, `undo_scope` has no `try/finally`, and node z-order is not serialised. Recorded in `progress.md` #4–#7. + +## U-20260923-11 · 2026-09-23 · Image sources, undo scopes and stacking order · #done #diagram + +- **What**: `progress.md` #4, #6 and #7, all from the same audit as U-20260923-10. + - **#4 where an image may come from.** `_try_load_image_source()` asked `Path.is_file()` before checking the extension, so a `source` in a `.diagram.json` written by someone else decided what the IDE touched. On Windows that stat is the dangerous part: a UNC path makes the SMB client authenticate to whatever host it names, handing over the user's NTLM response, and an unreachable host blocks the UI thread until SMB gives up. The extension is now checked first, and `_is_on_this_machine()` refuses UNC paths in both spellings (`\host\share`, `//host/share`) before the filesystem is touched at all. + - **#6 undo scopes.** `undo_scope()` had no `try/finally`, so a body that raised — the Mermaid import catches one a frame up — left `_pending_undo_snapshot` set, and the next mouse release closed the scope: one unrelated click became an undo entry that reverted everything since. It now closes in `finally`. + - **#7 stacking order.** "Bring to Front" / "Send to Back" changed `zValue()` outside any undo scope and `to_dict()` never wrote it, so the order was lost on save and reset by any unrelated undo (which rebuilds every item from the dict). `z` is now part of a node's dict, read back through a numeric guard, and `_change_z()` runs in an undo scope. +- **Tests**: `test_diagram_serialization.py` gains three classes: where an image may come from (both UNC spellings and a non-image name never reach the filesystem, a local image path still does — all three fail with the order restored), a scope whose body raises leaving nothing pending, and stacking order surviving a save/load, being undoable, and ignoring a `z` that is not a number. +- **Result / numbers**: unit tests 1106 → 1114 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items}.py`, `test/test_utils/test_diagram_serialization.py`, `architecture_explore.md` §7, §18, `progress.md` (#4, #6, #7 removed). +- **Open items**: `progress.md` #5 (image downloads on the UI thread, repeated on every undo) is the one left from that audit. + +## U-20260923-12 · 2026-09-23 · The AI review panel kept API URLs, tokens and all · #incident #security + +- **What**: from the same audit round. `CLAUDE.md` § Security says an API URL may embed a token and is to be treated as a credential. The AI review panel wrote every URL sent, verbatim, to `~/.pybreeze/urls.txt` — a plaintext file that only ever grew — and logged `%r` of a failed request, whose message carries the whole URL, into `PyBreeze.log`. Two more from the same widget: `validate_url()` let `UnicodeError` escape (`http://<70-character label>.example` and `http://.example` raise it out of `socket.getaddrinfo`, and it is not a `gaierror`), as did `LookupError` from `read_capped_text()` when a server names a charset Python does not know; neither is in the handler's tuple, so in a Qt slot they leave the slot. +- **Fix**: the file keeps a SHA-256 fingerprint of each URL instead of the URL — enough to answer "sent before?", which is all it was used for — and a file still holding URLs from an older version is rewritten as fingerprints the next time anything is sent, so the tokens stop sitting in the user's home directory. The failure log names the exception type only. `validate_url()` turns `UnicodeError` into `UnsafeURLError` like any other name that cannot be looked up, and `read_capped_text()` falls back to its default encoding when the response names one Python does not know. A response that is not `ok` now says so (`HTTP 500 Internal Server Error`) instead of appending an empty body — with `allow_redirects=False`, a 302 used to leave the panel blank. +- **Tests**: `test_ai_code_review_client.py` (new, 6) covers what reaches the file, recognising a URL sent before, the rewrite of an older plaintext file, the status line, and a failed request not logging the token. `test_url_validation.py` gains four hostnames that cannot be looked up, `test_http_client.py` an encoding Python does not know. All the new ones fail against the old code. +- **Result / numbers**: unit tests 1114 → 1125 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `pybreeze/utils/network/{url_validation,http_client}.py`, `test/test_utils/{test_ai_code_review_client,test_url_validation,test_http_client}.py`, `docs/source/{Eng,Zh}/ai_tools.rst`. +- **Open items**: the same audit found that this panel sends its request on the UI thread (`SkillsSendGUI` already uses a `QThread`), and that the SSH tab leaves its reader thread and sessions running when it is closed. Recorded in `progress.md` #8 and #9. + +## U-20260923-13 · 2026-09-23 · Four findings from reviewing the day's work · #incident #review + +- **What**: a review of everything committed today, against `origin/dev`, found four defects in it. All four are confirmed and fixed here. + 1. **Three message boxes instead of one.** U-20260923-08 made `install_package()` report a missing editor tab; `install_build_tools()` calls it three times in a row and ignored the answer, so `Install ▸ Tools ▸ Install Build Tools` with a tool tab in front put the same dialog up three times. And `install_prthinker()` asked for the source folder, validated it and saved it to the settings *before* the install refused to start. `install_is_possible()` now answers the question on its own; both callers ask it before doing anything else, and `install_build_tools()` stops at the first no. + 2. **A model with nowhere to go.** U-20260923-02 moved the model name onto the chosen backend's variable. A stored backend PyBreeze does not offer (hand-edited, or written by a newer build) has no variable, so the model was dropped without a word while the dialog kept showing it. It is logged now. + 3. **Rule retrieval could still be turned off by the environment.** `remote` sent only `PRTHINKER_REMOTE_RAG=true`, and the child inherits the IDE's environment: a user who exports `PRTHINKER_RAG_ENABLED=false` for their own prthinker runs and starts PyBreeze from that shell got `--no-rag` anyway, with `/rag` never consulted. Both variables are sent in both modes now, which is what `architecture.md` §6 already claimed. + 4. **Debug mode's auto-close skipped the cleanup.** `debug_close()` called `app.quit()`, which leaves the event loop without delivering a close event, so under `debug_mode` — the startup tests — a run's child still outlived the process, the very leak U-20260923-06 fixed. It closes the window first. +- **Tests**: the build tools stopping at the first refusal and installing all three when they can; prthinker's source folder not being asked for when nothing can be installed; a model with no backend to send it to being reported; rule retrieval sending both variables in both modes (the old tests pinned the absence of one, which was the weaker behaviour); and the debug-mode auto-close stopping a run, alongside the window-close test. +- **Result / numbers**: unit tests 1125 → 1130 passed, 14 skipped; the 14 prthinker contract tests pass against prthinker `main` installed the way CI installs it. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/menu/install_menu/{install_utils,tools_menu/build_tool_install_menu,automation_menu/build_automation_install_menu}.py`, `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_install_menu,test_prthinker_setting,test_closing_the_ide}.py`, `architecture.md` §6, `architecture_explore.md` §5.5, §14, §18. +- **Open items**: the same review noted that run windows are never released (`progress.md` #13). + +## U-20260923-14 · 2026-09-23 · Closing an SSH tab left its thread and session running · #incident #ssh + +- **What**: `progress.md` #9 and #11. Nothing in `connect_gui/ssh/` had a `closeEvent`. JEditor's `close_tab()` calls `close()` on the widget it removes, and Qt delivers that event to that widget alone, so closing the SSH tab (or its dock) reached neither half: `SSHCommandWidget._cleanup()` was reachable only from the Disconnect button. The reader `QThread` kept running while its widget was collected — Qt aborts the process when a running QThread is destroyed, and a queued `data_received` lands in a deleted widget — and the paramiko transport and the SFTP session stayed open, sending keepalives, for the rest of the session. Separately, five `SFTPClientWrapper` methods (`mkdir`, `remove_file`, `remove_dir`, `rename`, `download`, `upload`) skipped the connection check that `list_dir` and `is_dir` had, so acting on a stale tree showed the user `AttributeError: 'NoneType' object has no attribute 'mkdir'` through the tree's blanket handler. +- **Fix**: `SSHCommandWidget.closeEvent()` runs the same `_cleanup()` as Disconnect, `SSHFileTreeManager.closeEvent()` closes the SFTP session, and `SSHMainWidget.closeEvent()` passes the close to both halves. Every SFTP call goes through one `_require_connection()`, which raises the "not connected" message the guarded ones already raised. +- **Tests**: `test_ssh_teardown.py` (new, 11) closes a shell widget with a stand-in reader thread, channel and client and checks all three are stopped and dropped; closes a widget that never connected; closes the file tree and the whole tab; and asserts every SFTP call without a connection raises `RuntimeError` rather than reaching `None`. This is the invariant `test_ai_gui_lifecycle.py` already pins for the AI widgets. +- **Result / numbers**: unit tests 1130 → 1141 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_command_widget,ssh_file_viewer_widget,ssh_main_widget}.py`, `test/test_utils/test_ssh_teardown.py` (new), `architecture_explore.md` §9, §18, `progress.md` (#9, #11 removed). +- **Open items**: `progress.md` #10 (SSH and SFTP work still runs on the UI thread) and #12 (one Connect button, two sessions) remain. + +## U-20260923-15 · 2026-09-23 · The AI review request froze the IDE while it waited · #done #ai + +- **What**: `progress.md` #8. The AI review panel sent its request in the button's slot: `requests.*` with a 10 s connect and 30 s read timeout, then a streamed body under a 16 MB cap, all on the UI thread. A slow endpoint froze every tab, the terminal and the run windows with it, with no progress and no way to cancel, and each further click queued another blocking request. `SkillsSendGUI` — the same request shape, the same helpers — had been doing it on a `QThread` all along. +- **Fix**: `ReviewRequestThread(QThread)` runs one request and reports through `answered` / `failed`, the only two things that touch the UI. `send_request()` ignores a click while one is in flight (replacing a running QThread risks destroying it mid-run and letting a stale answer overwrite a newer one), disables the send button until the answer arrives, and refuses a method it does not offer before starting anything. `closeEvent()` waits for a request still in flight with its signals blocked, the pattern `test_ai_gui_lifecycle.py` pins for the other AI widgets. URL validation moved into the thread with the request: it resolves the hostname, which blocks too. +- **Tests**: `test_ai_code_review_client.py` now drives the thread directly — an answer, a failed status, a request that fails without logging the URL, and a hostname that cannot be resolved being reported rather than raised — plus the in-flight guard, the close that waits, and an unsupported method starting nothing. +- **Result / numbers**: unit tests 1141 → 1146 passed, 14 skipped; both startup tests exit 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `architecture_explore.md` §9, §18, `progress.md` (#8 removed). +- **Open items**: none for this panel. SSH is still on the UI thread (`progress.md` #10). + +## U-20260923-16 · 2026-09-23 · A diagram fetched its images again on every undo · #done #diagram + +- **What**: `progress.md` #5. A diagram's URL images were fetched with `safe_download_image()` straight from `_try_load_image_source()`, on the UI thread, with a 15 s timeout each — and that path is reached from `_restore_from_dict()`, which is what undo and redo call. So a diagram with three images on a slow host froze the IDE for up to 45 s on opening it and again on every Ctrl+Z, and an image whose host had gone away came back blank after an undo of something else entirely, because the pixmap was never part of the snapshot. +- **Fix**: `ImageDownloadThread(QThread)` fetches one source and reports through `fetched` / `failed`; the scene keeps what comes back in `_pixmap_cache` for the session and hands it to every image with that source. A second image with the same source joins the fetch already going instead of starting another. Undo and redo take the cached pixmap and touch the network not at all. `DiagramEditorWidget.closeEvent()` waits for anything still in flight with its signals blocked, so no QThread is destroyed while running. `http.client.HTTPException` — what a hostile or broken server can raise through `urlopen` — is caught with the rest. +- **Tests**: `test_diagram_images.py` (new, 6): a fetch that has not answered yet does not hold up the load (the fake blocks on an event, and the load is back before it answers), what comes back reaches the image, two undos fetch nothing again, two images from one source share a fetch, a failed fetch is only logged, and closing the editor leaves no fetch going. +- **Result / numbers**: unit tests 1146 → 1152 passed, 14 skipped. `ruff check` clean, and both quality gates (`PLR0913`/`C901`, and `complexipy` at 15) report nothing. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_editor_widget}.py`, `test/test_utils/test_diagram_images.py` (new), `architecture.md` §7, `architecture_explore.md` §7, §18, `progress.md` (#5 removed). +- **Open items**: none for the diagram editor. + +## U-20260923-17 · 2026-09-23 · Run windows were kept for the whole session · #done #process-executor + +- **What**: `progress.md` #13. `current_run_code_window` only ever grew: every run, and every mis-click that opens the "no script tab" window, left a `CodeWindow` on the main window for the rest of the session — and since U-20260922-18 each also holds its executor, its two queues and its timer. Closing the window changed nothing. +- **Fix**: `CodeWindow.closeEvent()` emits `finished_and_closed` when nothing is running in it, and `open_run_window()` — the one place a run window is opened and registered now, used by both executors — has the main window drop it then. A window whose run is still going stays in the list: that is where the rest of the output goes, and it is the list the IDE's close stops the runs from. The IDE's close now walks a copy of the list, since a window that closes drops itself from it. +- **Tests**: `test_build_task_process.py` gains a closed window with nothing running being let go of, one with a run still going being kept, and closing three in a row leaving none behind. +- **Result / numbers**: unit tests 1152 → 1155 passed, 14 skipped; the startup test exits 0. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/show_code_window/code_window.py`, `pybreeze/extend/process_executor/process_executor_utils.py`, `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_build_task_process,test_prthinker_process}.py`, `architecture_explore.md` §4.2, §18. +- **Open items**: `progress.md` #2 is still the owner's to decide: whether closing a run window should stop the run it shows. + +## U-20260923-18 · 2026-09-23 · Copy and duplicate ignored images · #done #diagram + +- **What**: one of the smaller items from the diagram audit. `copy_selected()` collected only nodes and returned early when none were selected, so `Ctrl+C` / `Ctrl+D` with an image selected did nothing — or, worse, `Ctrl+V` afterwards pasted whatever nodes had been copied earlier, because the clipboard was left as it was. An image could never be copied or duplicated at all. +- **Fix**: the clipboard carries images alongside the nodes and connections, and a paste adds them at the same offset and selects them with the rest. The offset both use is `_PASTE_OFFSET` rather than a repeated 30. Loading a diagram that has two nodes with the same `id` now says so in the log: the later one still wins, but connections to that id silently pointing at a different node was worth a line. +- **Tests**: `test_diagram_images.py` gains duplicating an image on its own (two images, one offset by 30), copying a node and an image together, and a copy with nothing selected leaving the clipboard as it was. +- **Result / numbers**: unit tests 1155 → 1158 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_scene.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §7, §18. +- **Open items**: none. + +## U-20260923-19 · 2026-09-23 · An SFTP transfer held the IDE until it finished · #done #ssh + +- **What**: part of `progress.md` #10. `SFTPClient.get()` and `put()` have no timeout of their own and a file can be any size, and both ran in the context-menu handler: downloading a large file, or one over a link that stalls, froze every tab, every run window and the editor until it finished, with no progress and no way to stop it. +- **Fix**: `SftpTransferThread(QThread)` runs one transfer and reports through `done` / `failed`; the tree starts it, says so when one is already going (a paramiko SFTP session is not meant to be used from two places at once), shows where the file ended up when it finishes, and refreshes the folder after an upload only then. `closeEvent()` waits for a transfer still going with its signals blocked before closing the session. +- **Tests**: `test_ssh_teardown.py` gains four: a download whose fake blocks on an event returns to the caller while the thread is still going, a second transfer is refused and the user told, a transfer that fails is reported rather than raised, and closing waits for one still going. +- **Result / numbers**: unit tests 1158 → 1162 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_ssh_teardown.py`, `architecture_explore.md` §9, §18. +- **Open items**: the rest of `progress.md` #10 — connecting and listing a directory still run on the UI thread, and moving `connect()` needs the host-key prompt routed back through a signal. + +## U-20260923-20 · 2026-09-23 · One Connect button, two sessions, one honest label · #done #ssh + +- **What**: `progress.md` #12, and the name of the flaky test in #3. + - **The label.** The SSH tab's one Connect button opens two independent sessions — the shell and the file tree, each authenticating on its own — and each half set the shared status label, so it showed whichever finished last. A server with the SFTP subsystem disabled (a failure `test_sftp_remote_path.py` already expects) left "Connected" showing with no file tree, and a shell auth failure left "Disconnected" showing over a working file tree. `SSHMainWidget.report_connection_state()` now runs after both and says which of the two is up (four strings, both languages). + - **The flake.** `progress.md` #3 recorded a test that failed once under load without its name. It happened again, and this time with both: `test_startup_language.py::test_the_ide_starts_with_the_saved_language[Traditional_Chinese]`, exit `3221226505` (`0xC0000409`) — the fast-fail Qt uses when a running `QThread` is destroyed. The child started the IDE, reported, and ended without closing the window, so the interpreter tore down whatever JEditor toolbar thread was still running (its git branch scan). `started_window.py` closes the window before the child ends, which is also the path that stops those threads. Three runs of the three child-window tests, five times over, are green; it had been about one in ten under load. +- **Tests**: `test_ssh_teardown.py` gains the four states of the status label. The harness change is exercised by every test that uses it (`test_startup_language.py`, `test_started_menus.py`, `test_closing_the_ide.py`). +- **Result / numbers**: unit tests 1162 → 1166 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_main_widget,ssh_command_widget}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_ssh_teardown,started_window}.py`, `progress.md` (#3, #12 removed). +- **Open items**: the two sessions themselves remain two — one SSH connection carrying both the shell and the SFTP session would be a redesign of both widgets, and there is no server here to try it against. + +## U-20260923-21 · 2026-09-23 · The embedded JupyterLab took any origin and outlived its tab · #incident #security #jupyter + +- **What**: from an audit of `jupyter_lab_gui/`, `extend_ai_gui/` and `editor_main/`. Three things about the embedded JupyterLab, the first a security hole. + - **Any origin.** The server ran with an empty token and password and `--ServerApp.disable_check_xsrf=True` — which `CLAUDE.md` allows *only* because it binds to localhost — and also `--ServerApp.allow_origin=*`. A loopback bind does not stop a browser: with the origin open, any web page the user visited while the tab was open could call the server's REST API and read the answers (the port is found by trying, since the answers are readable) and open its kernel websockets, i.e. run code as the user. The view PyBreeze embeds loads `http://localhost:/lab` from the same origin and needs none of it. The flag is gone. + - **It never stopped.** `JupyterLabWidget.closeEvent()` stopped the server only while the launcher thread was running, but that thread ends the moment the lab is ready, so closing a loaded tab left `python -m jupyterlab` running, holding its port, reachable as above. And at IDE exit the widget's `closeEvent` was never called at all: JEditor's closes editor tabs only, and `start_editor()` leaves through `os._exit()`. The tab now always stops the server, and `PyBreezeMainWindow._close_tool_tabs_and_docks()` closes every non-editor tab and every `DestroyDock` before JEditor's close — which also gives the SSH tab, the diagram editor and the AI panels the closes they were written to expect. + - **Pipes nobody read.** The server's stdout and stderr were pipes read only if it died during startup; a server that kept logging would fill the buffer and block in `write()`, freezing the lab with nothing to show why. Its output now goes to a temporary file, which the early-exit message reads its tail from. +- **Tests**: `test_jupyter_lifecycle.py` (new, 6): the launch binds to localhost and sets no origin, its output does not go to a pipe, an early exit still reports what the server said, stopping twice is harmless, a tab whose launcher has finished still stops the server, and closing the real IDE (in a child) closes a tool tab and a dock. The last fails with the new call removed. `test_jupyter_ready.py`'s early-exit test feeds its message through the output file instead of a fake pipe. +- **Result / numbers**: unit tests 1166 → 1172 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/jupyter_lab_gui/{jupyter_lab_thread,jupyter_lab_widget}.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_jupyter_lifecycle,test_jupyter_ready}.py`, `CLAUDE.md` § Security › JupyterLab, `architecture.md` §6 (`DestroyDock` is now also used by `main_ui.py`), `architecture_explore.md` §10. +- **Open items**: the same audit's other findings are recorded in `progress.md` #14–#25. + +## U-20260923-22 · 2026-09-23 · Renaming an open file brought the old name back · #done #editor + +- **What**: `progress.md` #14. Renaming a file from the file tree while it was open changed the tab's `current_file` but not JEditor's auto-save thread, which loops for as long as the path it *started* with is a file and writes to its own `file`. So five seconds later it wrote the buffer back to the old name — recreating the file the user had just renamed away, which kept its loop going for the rest of the session — and the new name never received another save, while `rename_self_tab()` marked the tab as saved. Renaming a folder did the same to every open file under it, since the lookup matched exact file paths only. +- **Fix**: the rename finds every tab open on the path or under it (`_editors_under()`), stops each tab's auto-save *before* renaming so none writes to the old path mid-rename, and afterwards starts a fresh one on the new path — or on the old one again if the rename failed — through JEditor's own `init_new_auto_save_thread()`, which also carries the tab's encoding and line ending. The thread cannot be pointed elsewhere, only replaced: its loop holds the original path. The open-file registry is re-keyed the same way. +- **Contract**: that uses three more JEditor internals (`init_new_auto_save_thread`, `auto_save_manager_dict`, `file_is_open_manager_dict`), now listed in `architecture.md` §6 and pinned by `test_jeditor_contract.py`, with a shape check on the save thread (`still_run`, `file`). The contract test's check that every internal import is listed only read single-line imports; a parenthesised one would have slipped past it unlisted. It reads both now. +- **Tests**: `test_file_tree_rename.py` (new, 2) starts the real IDE in a child, opens a file, renames it — or its folder — through the context-menu action, edits the buffer, and waits for JEditor's auto-save: the edit reaches the new path and the old one does not come back. Both fail against the old code. The existing rename test moved to the new lookup. +- **Result / numbers**: unit tests 1172 → 1178 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/{test_file_tree_rename,test_file_tree_context_menu,test_jeditor_contract}.py`, `architecture.md` §6, `architecture_explore.md` §3, §18, `progress.md` (#14 removed). +- **Open items**: none. + +## U-20260923-23 · 2026-09-23 · Three tool tabs let an exception out of their slots · #done #tools + +- **What**: `progress.md` #16, #17 and #25, from the audit of `tools_gui/` and `utils/`. Each let an exception out of a Qt slot instead of saying what was wrong. + - **#16 JSON nested too deep.** `reformat_json()` and `minify_json()` caught `JSONDecodeError` only; the `json` module recurses once per level, so `"[" * 100000 + "]" * 100000` raised `RecursionError` straight through `JsonFormatGUI` — including from its constructor when the response inspector opens a body in it. `test_fuzz_pure_logic.py` already stated that contract, but `st.text()` never generates such input. Both now report it as bad JSON (`ITEJsonException`). + - **#17 A URL that cannot be split.** `url_to_json()` passed `urlsplit()`'s `ValueError("Invalid IPv6 URL")` through, and the URL builder's to-JSON path had no handler at all (its to-URL path did): `http://[::1` crashed the slot, and so did opening a cURL import's URL in the builder, which converts it in `__init__`. It raises `UrlConvertException` now and the tab shows `url_builder_parse_error`. + - **#25 A .har that is not UTF-8.** `open_file()` read with `encoding="utf-8"` inside `except OSError`, so a UTF-16 export raised `UnicodeDecodeError`. It is reported as `har_import_not_utf8` now, and a read error shows the OS's reason without the path — `str(OSError)` carries the file's full path, which `CLAUDE.md` § Security says is not to be shown. +- **Tests**: six new ones, all failing against the old sources: deep nesting for both JSON functions, the unclosed bracket in `url_to_json()` and in the builder tab, and in the HAR importer a UTF-16 file plus an error message that does not name the folder. +- **Result / numbers**: unit tests 1178 → 1184 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/tools_gui/{url_builder_gui,har_import_gui}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_json_process,test_url_convert,test_url_builder_gui,test_har_import_gui}.py`, `progress.md` (#16, #17, #25 removed). +- **Open items**: none. + +## U-20260923-24 · 2026-09-23 · Imported requests sent a different query than was recorded · #done #tools + +- **What**: `progress.md` #18, #19 and #20. Three places where a query string came out different from the one that went in, silently — the generated script tested another request, or the URL builder dropped a parameter. + - **#18 HAR.** `_apply_query()` stored the URL's values still percent-encoded, and `CurlRequest.full_url` and the generated `params = {...}` encode them again: `?q=hello%20world` was sent as `hello%2520world`, the literal text `hello%20world`. The cURL importer decoded (`parse_qsl`), so the two importers disagreed on the same URL. The HAR importer decodes now. + - **#19 `curl -G`.** Each `-d` fragment was split on its first `=` only, never on `&`: `-d 'a=1&b=2'` became `{"a": "1&b=2"}`, sent as `a=1%26b%3D2`, and `--data-urlencode 'q=hello world'` was encoded twice. With `-G` curl appends each fragment to the URL as given, so each is now read as query text and decoded once. + - **#20 URL builder.** `parse_url()` built its query with `dict(parse_qsl(...))`, keeping only the last value of a repeated key, so `?tag=a&tag=b` round-tripped to `?tag=b`. It uses the query tool's `query_to_dict()` — a repeated key becomes a list, as that tool already did — and `build_url()` encodes lists back as repeated keys (`doseq=True`). + - `parse_query_pairs()` had no callers left and went, with its three tests. +- **Tests**: six new ones, all failing against the old code — `-G` with two pairs in one fragment and with `--data-urlencode`, a HAR URL's encoded values and its rebuilt URL, and a repeated key through `parse_url()` and back. +- **Result / numbers**: unit tests 1184 → 1187 passed, 14 skipped (six added, three removed with the helper). `ruff check` clean. +- **Files**: `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/har_import/har_parser.py`, `pybreeze/utils/url_tools/url_convert.py`, `test/test_utils/{test_curl_import,test_har_import,test_url_convert}.py`, `progress.md` (#18, #19, #20 removed). +- **Open items**: none. + +## U-20260923-25 · 2026-09-23 · PySide6 6.11.2, after je_editor and frontengine · #done #dependencies + +- **What**: PyBreeze's part of workspace X-1 (JEDITOR, PyBreeze and FrontEngine on one PySide6). The published je_editor and frontengine pin PySide6 *exactly*, so PyBreeze could only follow them: moving first would have made `pip install pybreeze` unsatisfiable. The order was agreed with the session working the workspace backlog — JEDITOR and FrontEngine released first. Checked on PyPI before the bump: je_editor 1.0.26 and frontengine 1.0.78 both require `PySide6==6.11.2`. +- **Change**: `PySide6==6.11.2` in `requirements.txt`, `pyproject.toml` and `dev.toml` (the three are kept identical), and the version named in `README.md` and both translated READMEs. `architecture.md` §6's pin line now says why PyBreeze moves last rather than recording a stale "at last verification". +- **Result / numbers**: a fresh Python 3.13 venv from `dev_requirements.txt` resolves PySide6 6.11.2, je_editor 1.0.26 and frontengine 1.0.78 with `pip check` clean; in it, `test/test_utils/` gives 1187 passed, 14 skipped — including the JEditor contract test against 1.0.26 — and `start_automation_test.py` and `extend_automation_test.py` both exit 0 offscreen. +- **Files**: `requirements.txt`, `pyproject.toml`, `dev.toml`, `README.md`, `README/README_zh-CN.md`, `README/README_zh-TW.md`, `architecture.md` §6. +- **Open items**: none here; the workspace X-1 table is the other session's to update. + +## U-20260923-26 · 2026-09-23 · Dependabot opens its PRs against dev · #done #ci + +- **What**: `.github/dependabot.yml` had no `target-branch`, so Dependabot opened its pip PRs against the default branch, `main` — where every push runs the `publish` job and uploads a release to PyPI. `CLAUDE.md` says development work targets `dev`. It now says `target-branch: "dev"`, as the other workspace repositories do since today. +- **Evidence**: the file parses (`yaml.safe_load`) with `target-branch` set to `dev`. +- **Files**: `.github/dependabot.yml`. +- **Open items**: none. + +## U-20260923-27 · 2026-09-23 · PyBreeze.log leaves the working directory and becomes UTF-8 · #done #logging + +- **What**: `progress.md` #21, and PyBreeze's share of workspace X-6. `PyBreezeLogger` opened the relative path `PyBreeze.log` for overwrite at import: every process that imported `pybreeze` — the IDE, each test run, anything embedding it — left a log in whatever directory it started in and wiped the previous process's log, and from a read-only working directory `import pybreeze.utils…` failed outright. It wrote in the locale's code page, so on a cp1252 machine any record carrying Chinese text raised inside `emit()` and was dropped with `--- Logging error ---`. `CLAUDE.md` § File I/O already said PyBreeze writes under `~/.pybreeze/`. +- **Fix**: the scheme JEditor (`89d8dd5`) and FrontEngine adopted for X-6. The file is `~/.pybreeze/logs/PyBreeze.log`, or `$PYBREEZE_LOG_FILE`; it is opened on the first record (importing writes nothing), in UTF-8 with `backslashreplace`, for append, each line carrying the process id since every process on the account shares it. It is rotated to `.1` only when a process opens it — past `$PYBREEZE_LOG_MAX_BYTES`, 100 MB by default, the same variable as before — because Windows will not rename a file another process holds open, which would make rotation inside `emit()` fail on every later record. A file that cannot be opened turns file logging off with one `RuntimeWarning` instead of failing the caller. `app_dirs.pybreeze_data_path()` gives the data directory without creating it, for a caller that must not touch the disk at import. +- **Tests**: `test_logger.py` gains six: the default path, the environment override, nothing opened at import, Chinese, German and Cyrillic kept, a second run appending rather than wiping, and an unopenable path warning instead of raising. A real startup (`start_automation_test.py`) run from a scratch directory left no `PyBreeze.log` there and wrote `~/.pybreeze/logs/PyBreeze.log`. +- **Result / numbers**: unit tests 1187 → 1193 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/logging/logger.py`, `pybreeze/utils/app_dirs.py`, `test/test_utils/test_logger.py`, `architecture_explore.md` §12, §17, §18, `progress.md` (#21 removed). +- **Open items**: none here; workspace X-6 is left with WebRunner. + +## U-20260923-28 · 2026-09-23 · One broken extend tab no longer stops the IDE from starting · #done #editor + +- **What**: `progress.md` #24. `EDITOR_EXTEND_TAB` is exported from the package facade for third parties to register their own tabs, and `PyBreezeMainWindow.__init__` built each one in an unguarded loop, before anything was shown: one registered widget whose constructor raised meant `start_editor()` ended in a traceback and there was no IDE at all. +- **Fix**: `_add_extend_tabs()` builds each tab on its own and logs one that fails, by name; the rest of the window, and the other tabs, come up. The `except Exception` carries a `noqa` with its reason, the pattern `run_dir_files_with_package()` already uses for a batch that must not stop on one bad entry. +- **Tests**: `test_started_menus.py` starts the real IDE in a child with one tab that raises and one that does not registered first: the good one is there and the IDE started. It fails without the guard. The child-window harness (`started_window.py`) gained `before_window`, code run once the application exists but before the window is built. +- **Result / numbers**: unit tests 1193 → 1194 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/main_ui.py`, `test/test_utils/{test_started_menus,started_window}.py`, `architecture_explore.md` §3, §18, `progress.md` (#24 removed). +- **Open items**: none. + +## U-20260923-29 · 2026-09-23 · Closing a request panel no longer waits for its request · #done #ai + +- **What**: `progress.md` #23, and the same flaw in the panel threaded earlier today (U-20260923-15). + - `SkillsSendGUI`'s `RequestThread` declared `finished = Signal(str)` for its answer, hiding `QThread.finished`, the signal that says the thread is over. So an exception outside `run()`'s handler ended the thread with no signal at all, and the send button stayed disabled for the rest of the session. The answer now travels on `answered`, and `QThread.finished` re-enables the button however `run()` ended. + - Both request panels (`SkillsSendGUI` and the AI review panel) waited on close for a request still in flight, on the UI thread, with no timeout: up to the 10 s connect plus a 30 s read, the IDE frozen. They hand the thread to `thread_keeper.let_run_out()` instead, which disconnects it from the panel and keeps a reference until its own `finished` — never destroyed while running, never waited for. +- **Tests**: `test_ai_gui_lifecycle.py`'s skills test pinned the waiting; it now pins the opposite (not awaited, cut off, kept), plus a real `RequestThread` let run out and released when it ends, and `RequestThread.finished` being `QThread.finished`. The AI review panel's close test likewise. +- **Result / numbers**: unit tests 1194 → 1196 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/thread_keeper.py` (new), `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/{test_ai_gui_lifecycle,test_ai_code_review_client}.py`, `CLAUDE.md` (architecture tree), `architecture.md` §7, `architecture_explore.md` §8, §9, §18, `progress.md` (#23 removed). +- **Open items**: none. + +## U-20260923-30 · 2026-09-23 · Prompt files: other encodings, unsaved edits, half-written saves · #done #ai + +- **What**: `progress.md` #22 — the prompt files under `~/.pybreeze/prompts/`, which the editors advertise as editable by hand — and one crash found while testing it. + - **Another encoding.** `load_prompt()` and the editor read with `encoding="utf-8"` inside `except OSError`, so a prompt saved as "ANSI" (cp950 on a Traditional Chinese Windows) raised `UnicodeDecodeError` out of the skills panel's constructor and the prompt editor's: the tab or dock did not open. `read_prompt_file()` reads `utf-8-sig` (a byte-order mark no longer ends up in the prompt) and treats an undecodable file as unreadable, so a review falls back to the built-in rather than send mojibake. The editor still opens such a file, with the unreadable characters replaced and a note that saving writes it back as UTF-8. + - **Unsaved edits.** The editor's file watcher reloaded the file over whatever had been typed whenever it changed on disk. It now asks first when there are unsaved edits, and reloads silently only when there are none; a save clears the flag. + - **Half-written saves.** `save_prompt_text()` opened the file for writing, truncating it first, so a failure part-way left an empty prompt — which a review then quietly replaced with the built-in. It writes beside the file and `os.replace()`s it in, and its error box shows the OS's reason without the path. + - **A crash.** The editor's `QFileSystemWatcher` had no parent and the editor no `closeEvent`, so a change on disk after the editor closed was delivered to a widget on its way out — with unsaved edits, a modal question over it, which took the test process down with a heap corruption (`0xC0000374`). The watcher is parented to the editor, and closing the editor stops watching. +- **Tests**: `test_prompt_store.py` gains nine: a cp950 file falling back, a BOM stripped, the editor opening a cp950 file and saying so, unsaved edits kept when the user declines a reload and a reload without asking when nothing is unsaved, a failed save leaving the last good file and no leftovers, the watcher parented, and closing stopping it. Five fail against the old code. An older test in the same file replaced `QMessageBox.information` for the rest of the session instead of through `monkeypatch`; it no longer does. +- **Result / numbers**: unit tests 1196 → 1204 passed, 14 skipped, in two consecutive full runs. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py`, `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/{prompt_editor_widget,prompt_file_io}.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/test_prompt_store.py`, `architecture_explore.md` §8, §18, `progress.md` (#22 removed). +- **Open items**: none. + +## U-20260923-31 · 2026-09-23 · Importing a utility no longer imports the whole IDE · #done #refactor #performance + +- **What**: a refactor of the package facade, no change to its names. `pybreeze/__init__.py` imported `main_ui` and JEditor eagerly, and Python runs a package's `__init__` before any of its submodules, so `import pybreeze.utils.regex_tools.regex_tester` — or any other utility — first imported PySide6, JEditor and every menu builder. Measured with `-X importtime`: 4.10 s for that one import. It surfaced while moving the regex tester into a worker process, which would have paid it on every run (see the next entry), but every test process and every tool that only needs a utility paid it too. +- **Change**: the facade resolves its six public names on first use through a module `__getattr__` (PEP 562), with the same `__all__`, and the imports repeated under `TYPE_CHECKING` for type checkers. `from pybreeze import start_editor`, `pybreeze.EDITOR_EXTEND_TAB` and `from pybreeze import *` behave as before and return the very same objects. +- **Tests**: `test_package_facade.py` (new, 3, each in a fresh interpreter): importing a utility loads neither PySide6, JEditor nor `main_ui`; every public name resolves to the IDE's own object (`EDITOR_EXTEND_TAB is main_ui.EDITOR_EXTEND_TAB`, and so on); an unknown name is an `AttributeError`. +- **Result / numbers**: the same import takes 0.07 s. Unit tests 1204 → 1207 passed, 14 skipped; both startup tests exit 0 (the extend-tab one registers through `from pybreeze import EDITOR_EXTEND_TAB`). `ruff check` clean. +- **Files**: `pybreeze/__init__.py`, `test/test_utils/test_package_facade.py` (new), `architecture_explore.md` §15, §18. +- **Open items**: none. + +## U-20260923-32 · 2026-09-23 · A runaway regex no longer freezes the IDE · #done #tools + +- **What**: `progress.md` #15. The regex tool ran the user's pattern in the button's slot with nothing to bound it. `(a+)+$` against 26 `a`s and a `b` takes about 5 s and each further character doubles it, so a nested quantifier over a pasted log line could hold the IDE for minutes, "Not Responding", with killing the process the only way out. `_MAX_MATCHES` capped how many matches were reported, not how long one attempt took. Python's `re` cannot be interrupted once a match starts; only a process can be stopped mid-match. +- **Fix**: `find_matches_bounded()` compiles the pattern here first (a malformed one is reported without starting anything), then runs `find_matches()` in a `spawn` process and stops it if it has not answered within `MATCH_TIMEOUT_SECONDS` (5 s), reporting why. The tab waits for it on `RegexMatchThread`, so even those 5 s leave the IDE usable; the Test button comes back when the thread ends, and closing the tab hands a run still going to `thread_keeper.let_run_out()`. A normal run takes about 0.1 s — made possible by the lazy facade (U-20260923-31); with the old one, the worker spent about 5 s importing the IDE before matching anything. +- **Entry points**: a spawned child re-runs the main script, so `pybreeze/__main__.py` and `exe/start_pybreeze.py` start the IDE only under `if __name__ == "__main__":`, after `multiprocessing.freeze_support()` for the packaged executable; `exe/start_pybreeze.py` ran `start_editor()` at module level and would have opened a second IDE in the worker. Checked by running it with `run_name="__mp_main__"`, as the child does: no IDE. +- **Tests**: `test_regex_tester.py` gains three (same result as `find_matches`, a malformed pattern reported before any process, `(a+)+$` on 40 `a`s stopped); `test_regex_gui.py` waits for the thread in its existing tests and gains two (the tab does not wait for a slow pattern and gives the button back, a runaway pattern reported in the output). +- **Result / numbers**: unit tests 1207 → 1212 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/regex_tools/regex_tester.py`, `pybreeze/utils/exception/exception_tags.py`, `pybreeze/pybreeze_ui/tools_gui/regex_gui.py`, `pybreeze/__main__.py`, `exe/start_pybreeze.py`, `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py`, `test/test_utils/{test_regex_tester,test_regex_gui}.py`, `architecture.md` §3, `architecture_explore.md` §6, §18, `progress.md` (#15 removed). +- **Open items**: none. + +## U-20260923-33 · 2026-09-23 · Connecting an SSH tab no longer freezes the IDE · #done #ssh + +- **What**: `progress.md` #10, its connect half. Both halves of the SSH tab called `SSHClient.connect()` in the Connect button's slot. Against a host that does not answer, that is the 10 s TCP timeout, then paramiko's banner (15 s) and auth (30 s) timeouts, and the tab connects twice (shell, then file tree), so the IDE could sit "Not Responding" for over a minute. The connect could not simply move to a thread: the host-key policy shows a modal box from inside `connect()`, on whatever thread is connecting, and a widget built off the UI thread aborts Qt. +- **Fix**: `HostKeyAsker` in `ssh_host_key_policy.py` is a `QObject` on the UI thread that shows the question. Asked from the UI thread it shows it directly; asked from another thread it goes over a `BlockingQueuedConnection`, so the connecting thread waits for the answer and the UI does not. `host_key_asker()` creates it on first use, and both SSH widgets call it when they are built so it exists on the UI thread before any connect. New `ssh_connect_thread.SshConnectThread` runs one connect and emits `connected` or `failed(message)`. The shell prepares on the UI thread (validation, key-file check, cleanup of the prior session, new client, policy) and connects on the thread. The shell opens on `connected` only if that client is still the widget's; a connect that finishes after a disconnect or a newer connect is closed. The file tree lists the root on `connected`. A second Connect while one is in flight is ignored. Closing a widget mid-connect hands the thread to `thread_keeper.let_run_out()` and closes whatever session the late connect leaves. This is hooked to `finished` *after* `let_run_out`, which disconnects everything on `finished`. +- **Status label**: `SSHMainWidget` reported both sessions when the button was clicked, which is now before either has connected. Each half now emits `state_changed` when it comes up, fails or disconnects, and the label is reported from that. +- **Tests**: `test_ssh_reentrancy.py` rewritten on real widgets (it built one with `__new__` and asserted a synchronous order). It has ten tests: + - the prior session is torn down before the new one; + - the connect returns before the host answers and runs on another thread, and the shell opens on the UI thread; + - a second click is ignored; + - a failure is reported and the client dropped; + - closing mid-connect closes the late session, for the shell and for the file tree; + - the file tree lists the root on the UI thread after its connect, and reports a failure; + - the asker answers directly on the UI thread, and a question from a plain worker thread is shown on the UI thread. +- **Also**: `_cleanup()`'s three debug logs use lazy `%r` instead of f-strings. +- **Result / numbers**: unit tests 1212 → 1221 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_connect_thread (new),ssh_host_key_policy,ssh_command_widget,ssh_file_viewer_widget,ssh_main_widget}.py` + - `test/test_utils/test_ssh_reentrancy.py` + - `architecture.md` §7 + - `architecture_explore.md` §9, §16, §18 + - `progress.md` (#10 narrowed to listing) +- **Open items**: directory listing (`listdir_attr`) still runs on the UI thread, and nothing bounds it (#10). + +## U-20260923-34 · 2026-09-23 · Remote directories are listed without freezing the IDE · #done #ssh + +- **What**: `progress.md` #10, its listing half (the connect moved in U-20260923-33). `populate_children()` called `listdir_attr()` in the UI thread's slot. It has no timeout and returns every entry at once, so expanding a directory of tens of thousands of files, or any directory on a server that had stalled, held the IDE until the answer came. +- **Fix**: each listing, whether of the root, an expanded directory or a refresh, runs on a new `SftpListThread`. The thread sorts the entries there with `sort_entries()`, now a module function. The item shows "Loading…" until the rows arrive. They are filled in only if the tree has not been cleared since (`_tree_generation`, bumped by `_clear_tree()` on load and disconnect, because cleared items are deleted) and the item is still waiting for this listing: the serial under `LISTING_ROLE` lets a refresh started meantime supersede the older listing. A failure is reported on the item it was for, and a stale one is dropped. Closing the widget hands the listings still running to `thread_keeper.let_run_out()`. `list_dir()` takes the SFTP client into a local variable, so a close from the UI thread between the check and the call raises "not connected" instead of `AttributeError`. +- **Also fixed**: `load_root()` listed the root twice. `setExpanded(True)` on the root, which still had its placeholder, fired `itemExpanded`, and `on_item_expanded` listed it, then `load_root` listed it again. Neither cleared the other's rows, so every root entry appeared twice. This was found because the new code started two listings. The root is now listed once, before it is expanded. +- **Tests**: new `test_sftp_listing.py` has five tests: + - the root fills from another thread and shows "Loading…" first; + - a listing that returns after a disconnect is dropped; + - a refresh supersedes the listing before it, even when the older one returns last; + - a failed listing is reported; + - closing mid-listing lets it run out. + `test_sftp_remote_path.py` imports `sort_entries` directly. +- **Result / numbers**: unit tests 1221 → 1226 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py` + - `test/test_utils/{test_sftp_listing (new),test_sftp_remote_path}.py` + - `architecture.md` §7 + - `architecture_explore.md` §9, §16, §18 + - `progress.md` (#10 removed) +- **Open items**: none. + +## U-20260923-35 · 2026-09-23 · Adding an image from a URL no longer freezes the IDE · #done #diagram + +- **What**: the diagram editor's Add Image from URL called `safe_download_image()` in the menu action's slot. Everything ran on the UI thread and held the IDE until the host answered: the URL check's DNS lookup, the 15 s timeout, which applies to each read and not to the whole download, and a body of up to 20 MB. Images in a loaded diagram had already moved to `ImageDownloadThread` (U-20260923-16); this path had not. The slot also caught `Exception`, which the project rules forbid. +- **Fix**: the action starts an `ImageDownloadThread` and returns. When the image arrives it goes on the canvas. A failure, or data that is not an image, is shown in the same warning as before. `ImageDownloadThread` also catches `ValueError`, from a URL urllib cannot parse; before, that ended the thread without either signal. When the editor closes, its fetches and the scene's are handed to `thread_keeper.let_run_out()` instead of being waited for on the UI thread. For that, `DiagramScene.stop_image_downloads()` becomes `let_image_downloads_run_out()`. +- **Tests**: `test_diagram_images.py`: + - the closing test now checks that the fetch is kept until it ends, not waited for; + - four new tests: the image arrives without holding the UI; a failed fetch is reported; data that is not an image is reported; closing mid-fetch lets the fetch run out. +- **Result / numbers**: unit tests 1226 → 1230 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/{diagram_editor_widget,diagram_scene}.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §7, §18. +- **Open items**: none. + +## U-20260923-36 · 2026-09-23 · Mailing a run's report no longer freezes the IDE · #done #executor + +- **What**: `send_after_test()` is a run's done-hook. `TaskProcessManager` calls it on the UI thread as the run ends, and it mailed the report right there. `SMTPWrapper` extends `SMTP_SSL`, which connects to smtp.gmail.com:465 as it is built, so the connect, the login and the send each held the IDE for as long as the mail server took. It also connected and logged in before checking that there was a report and a user to send it to. It never quit the connection when a step failed. It caught `Exception` (observation 4 in `architecture_explore.md` §19). +- **Fix**: `send_after_test()` starts a daemon thread named `pybreeze-report-mail` and returns. The work is now `send_report()`: + - it checks the report file and the mail user first, and connects only when both are there; + - the client is used as a context manager, so it quits however the block ends; + - it catches the import failure of `je_mail_thunder` on its own, then only `OSError` (the socket and every `smtplib` error), `ValueError` (a report that is not UTF-8) and `MailThunderException`; + - it logs with lazy formatting. + Finding the user moved into `_mail_user()`. +- **Tests**: new `test_mail_report.py` uses a stand-in `je_mail_thunder`. It has 11 tests: + - the hook returns before the mail is sent; + - the report goes to the content file's user, or else the environment's; + - no user, or no report, means no connection; + - a failed login, a failed send (socket error or `MailThunderException`), an unreachable server, a report that is not UTF-8, and a missing `je_mail_thunder` are each logged, never raised, and the connection is closed. +- **Result / numbers**: unit tests 1230 → 1241 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py` + - `test/test_utils/test_mail_report.py` (new) + - `architecture.md` §4 + - `architecture_explore.md` §16, §18, §19 (observation 4 removed) +- **Open items**: none. + +## U-20260923-37 · 2026-09-23 · Blind catches narrowed to what each call raises · #done #quality + +- **What**: ten handlers caught `Exception` without re-raising and without a stated reason. CLAUDE.md's quality gates allow that only to log and re-raise. Each one hid whatever error it was not written for: a programming error in a diagram load, for example, reached the user as a "could not open" warning. +- **Fix**: each handler now catches what its call can raise: + - SSH shell: + - sending a command: `OSError`, `SSHException`; + - stopping the reader: `RuntimeError`, from a deleted C++ object; + - closing the channel or the client: new `CLOSE_ERRORS`. + - `_on_data` lost its try entirely: `decode(errors="replace")` cannot fail. The message key it used, `ssh_command_widget_error_message_decode_failed`, is removed from both languages. + - SFTP context menu: `CONNECT_ERRORS`. + - AI review statistics save: `OSError`. + - Opening a diagram: `OSError`, `ValueError`, `TypeError`, `KeyError`. + - Mermaid import: `ValueError`, `TypeError`, `KeyError`. + - JEditor's `startup_setting`: `OSError`, `ValueError`, `TypeError`, `KeyError`, `RuntimeError`. A bad saved setting still does not stop the IDE starting. + - JupyterLab launch: `OSError` (which includes the startup `TimeoutError`), `ValueError`, `RuntimeError`, `SubprocessError`. + The logging in the touched handlers is lazy. +- **Guard**: new `test_no_blind_except.py` walks every module's syntax tree and fails on an `except Exception`/`BaseException`/bare `except` that neither re-raises nor carries `# noqa: BLE001` followed by a reason. Run against the previous `main_ui.py`, it flags line 152. The handlers still allowed are the four with a stated reason (a third-party tab, a run's done-hook, a batch action, the PNG/SVG exports), the SSH reader's, and the SFTP connect's log-and-re-raise. +- **Result / numbers**: unit tests 1241 → 1242 passed, 14 skipped. `ruff check` clean, and `ruff --select BLE001` finds nothing. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_command_widget,ssh_file_viewer_widget}.py` + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` + - `pybreeze/pybreeze_ui/editor_main/main_ui.py` + - `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py` + - `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py` + - `test/test_utils/test_no_blind_except.py` (new) + - `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-38 · 2026-09-23 · Closing a tab no longer waits for its worker, and a closed JupyterLab tab starts no server · #done #jupyter #ssh #ai + +- **What**: three widgets still waited for their worker on the UI thread when they closed. The IDE was frozen for as long as the worker took: + - **JupyterLab**: its tab waited for the launcher, which may be running `pip install jupyterlab`, which is allowed 300 s. Worse, `stop()` found no server during the install, so after the wait the launcher went on and started one, token-less on localhost, with its tab gone and nothing left to stop it. The same happened, in a smaller window, to a close between the install check and the start. + - **CoT review**: closing waited for the current request, up to its read timeout. + - **SFTP tree**: closing waited for a transfer still going, however large the file. +- **Fix**: + - The launcher has a `_stopped` event and a `_process_lock`. `stop()` sets the event and takes the server under the lock. The launcher checks the event under the same lock before `_start_server()`, so once `stop()` has run no server is started. `stop()` can now be called from any thread. + - The tab lets a running launcher run out and then stops it. It no longer blocks the launcher's signals: `blockSignals` also blocks `finished`, so thread_keeper would never have let the launcher go. This was found by the new test. + - The CoT panel asks for an interruption, which stops after the current request, and lets the thread run out. + - The SFTP tree lets a transfer run out and closes the session when it ends. Closing the session under the transfer would have left half a file behind. +- **Tests**: + - `test_jupyter_lifecycle.py`: a launcher stopped during the install check starts nothing; closing the tab returns with the launcher kept, stopped, and released once it ends. + - `test_cot_session_reuse.py`: closing mid-review returns, and the interrupted thread is released when it ends. + - `test_ai_gui_lifecycle.py`: the CoT close test now asserts interrupted, not waited for, and kept. + - `test_ssh_teardown.py`: the wait-on-close test is replaced by a real transfer whose session stays open until it ends and then closes. The earlier transfer test now drains its own `done` signal: left pending, it opened a real modal box in the next test. +- **Result / numbers**: unit tests 1242 → 1245 passed, 14 skipped. Startup tests exit 0. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/jupyter_lab_gui/{jupyter_lab_thread,jupyter_lab_widget}.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py` + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `test/test_utils/{test_jupyter_lifecycle,test_cot_session_reuse,test_ai_gui_lifecycle,test_ssh_teardown}.py` + - `architecture_explore.md` §8, §9, §10, §18 +- **Open items**: the plugin compile step (`file_runner_process.py` `_compile_and_run`) still runs the compiler on the UI thread for up to 60 s (`progress.md` #26). + +## U-20260923-39 · 2026-09-23 · A plugin's compile step streams instead of freezing the IDE · #done #executor #plugin + +- **What**: `progress.md` #26. A plugin run config with `compile_then_run` ran its compiler with `subprocess.run(..., timeout=60)` in the Run with… slot. The IDE was frozen for the whole compile, up to a minute. The compiler's output appeared only once it had finished. The run window's Stop could not reach the compile, because there was no process to stop yet. +- **Fix**: + - The compiler now goes through `_start_process()`, the same streamed path as the run, with two new parameters. `after_exit` receives the exit code on the UI thread in place of the exit line; the compile step uses it to start the binary on 0 and to print `[Compile failed] exit code N` otherwise. `time_limit` is checked by the pump, which terminates the child past `COMPILE_TIME_LIMIT_SECONDS` (60) and says so. + - Both, and the binary to clean up, are set only once the child has started, so a compiler that cannot be found leaves nothing behind. + - The compile and the run share one `QTimer` instead of creating a new one per child. + - Stop now reaches a compile in progress. +- **Tests**: new `test_compile_then_run.py`. The compiler is Python running a small script; the run step is recorded rather than executed. It has four tests: + - `run_file` returns while the compiler is still working, its output streams in before it ends, and the binary runs after exit 0; + - a failed compile shows the compiler's error and `[Compile failed] exit code 3`, and nothing runs; + - a compile past its time limit is stopped and reported; + - a missing compiler is reported. + The first test fails on the old code, where `run_file` returned only after the compile. +- **Result / numbers**: unit tests 1245 → 1249 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/file_runner_process.py` + - `test/test_utils/test_compile_then_run.py` (new) + - `architecture_explore.md` §4, §18 + - `progress.md` (#26 removed) +- **Open items**: none. + +## U-20260923-40 · 2026-09-23 · SSH connects refuse SHA-1 on any paramiko (CVE-2026-44405) · #done #security #ssh + +- **What**: CVE-2026-44405 (CVSS 3.4): paramiko through 4.0.0 accepts RSA signatures made with SHA-1. paramiko 5.0.0 (2026-05-09) removed SHA-1 entirely: `ssh-rsa` signatures and the three SHA-1 Diffie-Hellman key exchanges. `requirements.txt` and `pyproject.toml` do not pin paramiko, so what a user gets depends on when they installed. This machine's `.venv` has 4.0.0, and a real `Transport` there offers six SHA-1 entries by default: `ssh-rsa` and `ssh-rsa-cert-v01@openssh.com` host keys, `ssh-rsa` auth, and `diffie-hellman-group1/14/group-exchange-sha1`. +- **Fix**: new `SHA1_ALGORITHMS` in `ssh_connect_thread.py` is passed as `disabled_algorithms` to all four `connect()` calls: the shell's password and key paths, and the SFTP wrapper's. With it, paramiko 4.0.0 offers no SHA-1, and RSA keys still work through `rsa-sha2-256/512`. paramiko 5.0.0 accepts the same list. This matches OpenSSH, which has refused `ssh-rsa` signatures by default since 8.8. CLAUDE.md § Security › SSH now requires it. +- **Checked against paramiko 5.0.0**, which CI installs because nothing pins it. The 5.0 breaking changes are SHA-1, GSSAPI, `demos/`, `PKey.write_private_key*` moved to the base class, and `from_path(passphrase=)` renamed to `password=`. None of them touch this code: the key loader uses only `RSAKey`/`Ed25519Key`/`ECDSAKey.from_private_key_file`. DSA went in 4.0 and was already not used. All 67 SSH/SFTP tests pass with 5.0.0 installed to a throwaway `--target` directory, without touching the shared `.venv`. +- **Tests**: + - `test_ssh_security.py`: a real `Transport` given `SHA1_ALGORITHMS` offers no SHA-1 and still offers `rsa-sha2-256`; + - `test_ssh_reentrancy.py`: the shell's connect and the SFTP wrapper's connect both pass it. + All pass on 4.0.0 and 5.0.0. +- **Result / numbers**: unit tests 1249 → 1252 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/{ssh_connect_thread,ssh_command_widget,ssh_file_viewer_widget}.py` + - `test/test_utils/{test_ssh_security,test_ssh_reentrancy}.py` + - `CLAUDE.md` § Security › SSH + - `architecture_explore.md` §9, §18 +- **Open items**: whether to require `paramiko>=5.0.0` is a dependency-policy decision for the owner, like every pin here. + +## U-20260923-41 · 2026-09-23 · A redirect or an HTTP error no longer passes for an AI answer · #done #ai + +- **What**: the three AI request threads send with `allow_redirects=False` and judged the answer by `response.ok`. In `requests`, `ok` is true for every status below 400, checked here: a 302 has `ok == True`. So: + - **Skills**: a redirect took the `ok` branch and showed an empty answer. The branch meant for redirects, which reports the status and where it pointed, could never run. + - **AI code review panel**: same. Its comment said a 302 would not look like a success, but it did. + - **CoT review chain**: `_ask()` never looked at the status at all. An HTTP 500 error page counted as the step's answer and was quoted into every later step as if it were the model's findings. Failed steps are meant to be kept out of later prompts (`test_a_failed_step_is_shown_but_never_quoted_back`). +- **Found by**: a characterization test written before a planned refactor of the Skills thread's status branches. The redirect case failed against the unchanged code. +- **Fix**: + - New `http_client.succeeded(response)`: 2xx only. + - Skills and the AI review panel use it in place of `.ok`. + - The CoT step counts a non-2xx as a failed step. It is shown as `HTTP ` with the start of the body, logged, and not quoted onward. +- **Tests**: + - new `test_skills_request.py` pins every branch of the Skills thread: 2xx, redirect, 401/403, 5xx, another 4xx, connection failure; + - `test_http_client.py`: `succeeded()` on 2xx and on 1xx/3xx/4xx/5xx; + - `test_ai_code_review_client.py`: a 302 is reported as `HTTP 302 Found`; + - `test_cot_session_reuse.py`: an error page is a failed step and is not quoted onward. Its fake response now has a status. +- **Result / numbers**: unit tests 1252 → 1272 passed, 14 skipped. `ruff check` clean. Dependency audit, a throwaway venv resolving `requirements.txt` today with `pip-audit`: no known vulnerabilities. +- **Files**: + - `pybreeze/utils/network/http_client.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py` + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/code_review_thread.py` + - `test/test_utils/{test_skills_request (new),test_http_client,test_ai_code_review_client,test_cot_session_reuse}.py` + - `architecture_explore.md` §8, §12, §18 +- **Open items**: the Skills status branches and `AICodeReviewClient.__init__` (97 lines, over the 75-line cap) are still to be restructured. That is a refactor, so it is its own stage, now that the tests above pin the behaviour. + +## U-20260923-42 · 2026-09-23 · Skills status wording and the review panel's layout split out · #done #refactor #ai + +- **What**: two pieces of code over the quality gates, both finished as a refactor with no change in behaviour: + - `skills_send_gui.RequestThread.run` repeated the same `skills_error_status` formatting in four branches. + - `AICodeReviewClient.__init__` was 97 lines; the cap is 75. +- **Change**: + - New pure `describe_failed_status(response, body) -> (is_error, text)` decides what a non-2xx answer means. `run` formats it once and sends it to `error` or `answered`. + - `__init__` builds its layout from `_build_request_row()`, `_build_code_and_response()` and `_build_verdict_buttons()`; the longest function in either file is now 44 lines. +- **Guarded by**: `test_skills_request.py` (every status branch, written first, U-20260923-41), `test_ai_code_review_client.py` and `test_ai_gui_lifecycle.py`, unchanged and passing before and after. +- **Result / numbers**: unit tests 1272 passed, 14 skipped (unchanged). `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`. +- **Open items**: none. + +## U-20260923-43 · 2026-09-23 · The SSH terminal no longer breaks lines, characters or escapes where a read ends · #done #ssh + +- **What**: the SSH shell showed its output wherever the 4096-byte channel reads happened to end, and it showed it wrong there in three ways: + - **Line breaks**: every read went through `QPlainTextEdit.appendPlainText`, which always starts a new paragraph. A line split across two reads showed as two lines. Checked: `ab` then `cd` gave `ab\ncd`. + - **Characters**: each read was decoded on its own, so a multi-byte UTF-8 character cut by a read became replacement marks. Checked: `中文` cut after its fourth byte showed as `中���`, which hits any Chinese output. + - **Escapes**: an ANSI escape cut by a read left its tail (`1m`) in the text. + - **Lost output**: separately, the reader stopped as soon as the exit status was ready. Output that arrived together with the exit and did not fit in the last read was dropped. +- **Fix**: + - New `TerminalDecoder` in `ssh_command_widget.py`, with no Qt. An incremental UTF-8 decoder carries a cut character over. An unfinished escape at the end of a read (a lone ESC, a CSI without its final byte, an OSC without its terminator, up to 256 characters) is held for the next read. Escapes are then removed as before. It is reset for each new shell. + - Output is inserted at the end of the document with a `QTextCursor` and continues the line it belongs to. The view follows only if it was already at the bottom. + - `append_text` now carries only our own notices, and starts them on a line of their own. + - `SSHReaderThread._drain()` reads what is still buffered after the shell exits. +- **Tests**: new `test_ssh_terminal_output.py`: + - `中文` cut at each of its five inner byte positions comes back whole; + - `ESC[31m` cut at each position is still removed; + - text before an unfinished escape is shown at once; + - an overlong unterminated OSC is not held; + - `reset()` forgets what it carried; + - three reads continue one line; + - a notice after a partial line starts a new line, and after a finished line adds no blank line; + - output buffered at exit is not lost. +- **Result / numbers**: unit tests 1272 → 1288 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py`, `test/test_utils/test_ssh_terminal_output.py` (new), `architecture_explore.md` §9, §18. +- **Open items**: none. A lone carriage return, as in a progress bar redrawing its line, is still shown as a line break, as before. + +## U-20260923-44 · 2026-09-23 · A long output line no longer garbles its characters or doubles its line ending · #done #executor + +- **What**: the same read-boundary fault as the SSH terminal (U-20260923-43), in the run window. Both executors read a child's pipes with `readline(1024)`, so a line longer than 1024 bytes arrives in pieces. Each piece was decoded on its own. Reproduced on the previous code: + - a multi-byte character cut by the buffer was shown as replacement marks: `中文輸出` read in 4-byte pieces came out `中�����出`; + - a `\r\n` cut between its two bytes arrived as a piece ending in `\r` and a piece `\n`, which `normalize_line_endings` turns into two line breaks, a stray blank line; + - an encoding name the codecs do not know raised `LookupError` in the reader thread on the first line and ended it, so the run showed no output at all. +- **Fix**: `read_stream_into_queue()` decodes with an incremental decoder, which carries a cut character over to the next piece. It holds a trailing `\r` until the next piece arrives, and flushes both at EOF, so a final progress-bar `\r` is not lost. `_decoder_for()` falls back to UTF-8, with a warning, for an unknown encoding. +- **Tests**: `test_queue_pump.py` adds four: three-byte characters cut by a four-byte buffer are joined with no replacement marks; `\r\n` cut by the buffer never leaves a piece ending in `\r`; a trailing `\r` at EOF is kept; an unknown encoding decodes as UTF-8. The first, second and fourth fail on the previous reader. +- **Result / numbers**: unit tests 1288 → 1292 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/queue_pump.py`, `test/test_utils/test_queue_pump.py`, `architecture_explore.md` §4.5, §18. +- **Open items**: none. + +## U-20260923-45 · 2026-09-23 · Deleting a folder closes the tabs open inside it · #done #editor + +- **What**: deleting from the file tree closed only a tab open on exactly the deleted path. Deleting a folder left every tab open on a file inside it open, with JEditor's auto-save still running every 5 s. That save could write a file back while `shutil.rmtree` was removing the folder, which fails the delete part-way ("directory not empty") or leaves a file behind. And the tabs went on showing files that no longer existed, where a later save would bring them back. Rename had already been fixed to cover everything under a folder (`_editors_under`, U-20260923-22); delete had not. +- **Fix**: `_action_delete` closes every tab `_editors_under()` finds, before removing anything. `EditorWidget.close()` stops the tab's auto-save. The single-file lookup `_find_editor_for_file`, now unused, is removed. +- **Tests**: `test_file_tree_context_menu.py`: + - new: deleting a folder closes both tabs inside it, leaves a tab outside it open, and removes the folder; + - the open-tab test and the finding test now go through `_editors_under`. +- **Result / numbers**: unit tests 1292 → 1293 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `architecture_explore.md` §3, §18. +- **Open items**: none. + +## U-20260923-46 · 2026-09-23 · Package installs no longer go through cmd.exe · #done #security #install + +- **What**: the Install menu ran `pip install -U ` through JEditor's `ShellManager`, which starts its command with `shell=True`. On Windows the argument list is joined by `list2cmdline` and handed to `cmd.exe /c`. A target without spaces is not quoted, so `&`, `|`, `^` and `%` in it are cmd's. The prthinker install passes a source folder the user chose, remembered in settings, and a folder named like `R&D` is ordinary. Reproduced with the same call shape: the argument `D:\R&echo.SECOND-COMMAND-RAN` reached the program as `D:\R`, and `echo.SECOND-COMMAND-RAN` ran as a second command. This is the accidental shell injection that CLAUDE.md § Security › Subprocess requires argument lists with `shell=False` to prevent. +- **Also**: Install Build Tools started three pip processes one after another without waiting, so they ran at the same time against the same environment. Each started by clearing the panel the previous one was writing to. +- **Fix**: `install_packages(packages, main_window)` opens a run window with `build_task_process()` and calls `start_module_process("pip", ["install", "-U", *packages])`: PyBreeze's own executor, argv with no shell, output streamed like any run, and the interpreter chosen in the IDE with the usual fallbacks. Build Tools is one pip for all three. `install_package()` wraps one package. The editor-tab requirement existed only because the output went to that tab's shell panel, so it is gone with it: `install_is_possible()`, its message key `install_need_editor_tab_message` in both languages, and the prthinker install's pre-check. +- **Tests**: `test_install_menu.py` is rewritten: + - a package runs as `pip install -U `; + - a source folder holding `&` is one argument; + - Build Tools is a single `pip install -U setuptools build wheel`; + - a remembered prthinker folder is installed; + - end to end through the real `TaskProcessManager`, a module standing in for pip receives `D:\R&D|x^y%PATH%\prthinker[runner]` exactly. + The four tests about the editor-tab requirement went with it. +- **Result / numbers**: unit tests 1293 → 1291 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/install_menu/{install_utils,automation_menu/build_automation_install_menu,tools_menu/build_tool_install_menu}.py` + - `pybreeze/extend_multi_language/{extend_english,extend_traditional_chinese}.py` + - `test/test_utils/test_install_menu.py` + - `architecture_explore.md` §5.3, §5.5, §18 +- **Open items**: JEditor's `ShellManager` still uses `shell=True` for its own Run Shell feature. There it runs what the user typed as a shell command on purpose, and it is JEditor's to decide. + +## U-20260923-47 · 2026-09-23 · A request's method or URL can no longer write code into a generated script · #done #security #tools + +- **What**: `progress.md` #28. The curl and HAR importers write the request's method and URL into the scripts they generate, and two places did so as code rather than as data: + - **Test function name**: `script_templates.test_function_name` built `def test__...():` from the raw method. `curl -X 'GET():\n import os\ndef t' https://x/a` produced a pytest file whose syntax tree holds an `import os` and a second function. The same method in a HAR entry does the same. A legitimate `M-SEARCH` produced a SyntaxError. + - **Batch comment**: `har_codegen._numbered_comment` wrote `# N. ` raw. A recorded URL holding a newline ended the comment and put its rest on a line of code: `https://x/a\nimport os; os.system('calc') #` emitted a real `import os` in the batch script. + Everything else in those scripts goes through `json.dumps`, which was checked: URLs, headers and values are safe literals. + Found by a review of `tools_gui/` and `utils/`, each case reproduced with a concrete input. +- **Fix**: + - New `curl_parser.http_method()` upper-cases the method and accepts only an RFC 9110 token. Both parsers use it and refuse anything else with a parse error that names the value: `CurlParseException` for curl, `HarParseException` for HAR. + - The function name slugs the method (`M-SEARCH` becomes `test_m_search_...`). + - The batch comment writes control characters as `\xNN` escapes. + - New message `invalid_http_method_error`. +- **Tests**: + - `test_script_templates.py`: the injecting method is refused; `M-SEARCH` gives `def test_m_search_a():` and a file that parses. + - `test_har_import.py`: the injecting HAR method is refused; a URL holding a newline leaves the batch script with no import but `requests`, and the newline shows as `\x0a`. +- **Result / numbers**: unit tests 1291 → 1295 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{curl_parser,script_templates}.py` + - `pybreeze/utils/har_import/{har_parser,har_codegen}.py` + - `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/{test_script_templates,test_har_import}.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#28 removed) +- **Open items**: none for this item. The review's other findings are #29–#34. + +## U-20260923-48 · 2026-09-23 · Generated scripts write JSON bodies and non-BMP characters as Python · #done #tools + +- **What**: `progress.md` #29, and a second fault the new property test found while fixing it. + - **JSON body**: it was written into the generated Python with `json.dumps`. `-d '{"a": true, "b": null}'` became `json_body = {"a": true, "b": null}`, a `NameError` in the requests, pytest and APITestka Python scripts: `true`, `false` and `null` are not Python names. + - **Non-BMP characters**: every string the requests and pytest scripts carry (URL, headers, form fields, body, auth) was written with `json.dumps`'s ASCII-only default. That writes an emoji or a rare CJK character as a `\ud83d\ude00` surrogate pair: one character to JSON, but two lone surrogates to Python, which `requests` then cannot encode. The APITestka and LoadDensity templates already wrote such characters as themselves. +- **Fix**: + - `request_codegen.python_literal(value, inline=False)` writes a decoded JSON value as Python in the same `indent=4` layout: `True`, `False` and `None`, and `float("nan")`/`float("inf")` for the non-finite values Python's `json` accepts. It has a one-line form for the APITestka call. + - `python_string(text)` is `json.dumps(text, ensure_ascii=False)`, falling back to `repr` for a lone surrogate, which a UTF-8 file cannot hold. Every string in `request_codegen` goes through it. +- **Tests**: `test_script_templates.py`: + - no Python target contains `true`/`false`/`null` names; + - the requests script's `json_body` evaluates to exactly what was sent; + - a Hypothesis property: `literal_eval(python_literal(v)) == v` for any JSON value, one-line or not. It is what found the surrogate fault; + - non-finite floats are spelled out; + - `é`, `😀` and `𠀀` stay one character each, and a lone surrogate survives. +- **Result / numbers**: unit tests 1295 → 1300 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{request_codegen,script_templates}.py` + - `test/test_utils/test_script_templates.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#29 removed) +- **Open items**: none. + +## U-20260923-49 · 2026-09-23 · The diff tab's summary no longer takes a minute on a large text · #done #tools #performance + +- **What**: `progress.md` #30. The diff tab's Compare computed its "N added, M removed" line with `difflib.ndiff`, synchronously in the slot. `ndiff` also matches the characters inside every changed line, which the counts never use. Two 3000-line texts with every line slightly changed took 73 s in the review, with the IDE frozen. The unified diff shown under it took 0.01 s on the same input. +- **Fix**: `diff_summary()` counts from the opcodes of a `SequenceMatcher` over the lines, the same matching `unified_diff()` uses. `replace` and `delete` add to removed, `replace` and `insert` to added. The same input now takes milliseconds, and the counts now agree with the diff beside them by construction. +- **Tests**: `test_text_diff.py`: + - a Hypothesis property: the counts equal the `+` and `-` lines of the shown diff; + - 3000 changed lines are summarised in under 5 s, as `(3000, 3000)`. + The existing 20 diff tests pass unchanged. +- **Result / numbers**: unit tests 1300 → 1302 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/diff_tools/text_diff.py` + - `test/test_utils/test_text_diff.py` + - `architecture_explore.md` §6, §18 + - `progress.md` (#30 removed) +- **Open items**: none. + +## U-20260923-50 · 2026-09-23 · Five inputs that made a tool tab raise are reported instead · #done #tools + +- **What**: `progress.md` #31. Five inputs raised an exception the tool's handler did not name, so it left the tab's slot as a traceback instead of the tool's error message: + - **Timestamp**: `0001-01-01T00:00:00+01:00` or `9999-12-31T23:30:00-01:00` falls outside `datetime`'s range once moved to UTC. `astimezone()` raised `OverflowError`, and it was outside the `try`. + - **JSON format and minify**: an integer longer than Python's 4300-digit conversion limit raises a plain `ValueError`, not `JSONDecodeError`. + - **JWT decoder**: a payload nested past the recursion limit raised `RecursionError`. + - **Response inspector**: the same happened when pretty-printing such a body. + - **Header analyzer**: `int()` on a 5000-digit HSTS `max-age` raised `ValueError`. + Found by the review of `tools_gui/` and `utils/`, each reproduced there. +- **Fix**: + - The timestamp conversion to UTC is inside the `try`, which catches `(ValueError, OverflowError)`. + - The JSON tools catch `ValueError`, which covers `JSONDecodeError` and the digit limit. The now-unused `import json` is gone. + - The JWT decoder and the response inspector catch `RecursionError`. The inspector's serialisation is inside the `try` too. + - A `max-age` longer than 18 digits (`_MAX_AGE_DIGITS`) is centuries, so it counts as long enough without `int()`. +- **Tests**: one per fault, in each tool's test file: + - `test_timestamp_converter.py`: both edge dates; + - `test_json_process.py`: reformat and minify; + - `test_jwt_decoder.py`; + - `test_response_analyzer.py`; + - `test_header_analyzer.py`. +- **Result / numbers**: unit tests 1302 → 1308 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/{timestamp_tools/timestamp_converter,json_format/json_process,jwt_tools/jwt_decoder,response_inspector/response_analyzer,header_tools/header_analyzer}.py` + - the five matching `test/test_utils/test_*.py` + - `architecture_explore.md` §18 + - `progress.md` (#31 removed) +- **Open items**: none. + +## U-20260923-51 · 2026-09-23 · The timestamp tool keeps milliseconds and rounds times before 1970 down · #done #tools + +- **What**: `progress.md` #33. `convert_timestamp()` took whole seconds with `int(moment.timestamp())` and made milliseconds from them. So `1700000000123` came back as `epoch_millis` `1700000000000`: the milliseconds that were typed in were gone. `int()` also truncates toward zero, so `-1.5` (1.5 s before 1970) gave `epoch_seconds` `-1` instead of `-2`. +- **Fix**: milliseconds are computed from the instant itself, `(moment - _EPOCH) // timedelta(milliseconds=1)`, with integer arithmetic and no float rounding. Seconds are those milliseconds floor-divided by 1000. Both round down (toward the past), and the `TimestampResult` docstring now says so. +- **Tests**: `test_timestamp_converter.py`: `1700000000123` keeps its `123`; `-1.5` gives `-1500` ms and `-2` s; an ISO time with `.250` gives `...250` ms. +- **Result / numbers**: unit tests 1308 → 1311 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/timestamp_tools/timestamp_converter.py` + - `test/test_utils/test_timestamp_converter.py` + - `architecture_explore.md` §18 + - `progress.md` (#33 removed) +- **Open items**: none. + +## U-20260923-52 · 2026-09-23 · A text form field starting with @ is no longer uploaded as a file · #done #tools + +- **What**: three of the five import faults in `progress.md` #32. + - **`@` in text fields**: multipart fields were all kept in curl's `-F` syntax, where a value starting with `@` means "upload this file". A recorded HAR text field `handle=@alice` therefore became `files = {"handle": open("alice", "rb")}` in the generated script. curl's own `--form-string`, which exists to send such a value literally, was mapped onto `-F` and had the same fault. + - **BOM**: a HAR export saved with a UTF-8 byte-order mark was refused as "not a valid HAR export". + - **Non-finite timeout**: `--max-time nan` (or `inf`) passed the `float()` check and was written as `timeout=nan`, an undefined name in the script. +- **Fix**: + - `CurlRequest.form_strings` holds `name=value` fields taken literally; `has_form` covers both kinds. `--form-string` fills it, and so does a HAR text param; a HAR file param still goes to `form_fields` as `name=@file`. `form_parts()` adds the literal fields to the data fields, and the generators test `has_form`. + - The HAR tab reads files as `utf-8-sig`. + - `_apply_timeout` ignores a value that is not finite. +- **Tests**: + - `test_curl_import.py`: `--form-string` is literal and `-F @file` is still an upload; `nan`/`inf`/`-inf` timeouts are ignored. + - `test_har_import.py`: text params go to `form_strings` and files to `form_fields`; `@alice` stays data. + - `test_har_import_gui.py`: an export with a BOM loads. + - The two tests that pinned the old single list now pin the split. +- **Result / numbers**: unit tests 1311 → 1317 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{curl_parser,request_body,request_codegen,script_templates}.py` + - `pybreeze/utils/har_import/har_parser.py` + - `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py` + - `test/test_utils/{test_curl_import,test_har_import,test_har_import_gui}.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#32 narrowed) +- **Open items**: repeated query keys, and bash `$'...'` quoting (`progress.md` #32). + +## U-20260923-53 · 2026-09-23 · A query parameter given twice keeps both values · #done #tools + +- **What**: from `progress.md` #32. `CurlRequest.params` was a `dict[str, str]`, so a query parameter given more than once kept one value: + - `curl 'https://x/p?id=1&id=2'` came out as `?id=1`; + - `curl -G -d id=1 -d id=2` as `?id=2`; + - a HAR URL with a repeated key lost the same way. + A test pinned one form of this: `curl -G 'https://x?a=fromurl' -d 'a=fromdata'` kept only `fromdata`. Real curl 8.7.1, asked for `url_effective`, sends `?a=fromurl&a=fromdata` for that command and `?id=1&id=2` for the first. +- **Fix**: + - New `curl_parser.add_query_value()` keeps every value: a key seen once maps to its value, a repeated one to the list of its values. + - `full_url` encodes with `doseq=True`, and `requests` expands a list in `params` the same way. + - `parse_curl` splits the URL's query before adding `-G` data, so the order matches curl's. + - The HAR importer takes the URL's values and, from the recorded `queryString` (which repeats them), only names the URL lacked, with all of their values. + - The requests-script `params` block writes a list value with `python_literal`. +- **Tests**: + - `test_curl_import.py`: the pinned test now expects curl's answer, both values with the URL's first; a repeated URL key and a repeated `-G` key keep every value; the generated `params` evaluates to `{"id": ["1", "2"]}`. + - `test_har_import.py`: URL values are kept once, not doubled by `queryString`; a recorded name the URL lacks keeps all its values. +- **Result / numbers**: unit tests 1317 → 1322 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/{curl_parser,request_codegen}.py` + - `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/{test_curl_import,test_har_import}.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#32 narrowed) +- **Open items**: bash `$'...'` quoting (`progress.md` #32). + +## U-20260923-54 · 2026-09-23 · Copy as cURL (bash) commands with $'...' bodies import correctly · #done #tools + +- **What**: the last part of `progress.md` #32. Chrome and Firefox's Copy as cURL (bash) write a body holding a newline or a quote in bash's ANSI-C quoting, `--data-raw $'...'`. `shlex` does not know it: + - the body kept a leading `$`, so a JSON body was never recognised as JSON; + - an escaped quote, `$'it\'s'`, was refused as unbalanced. +- **Fix**: `_expand_ansi_c_quotes()` runs before `shlex`. It follows bash's quoting states, so `$'` counts only outside other quotes, and it rewrites each `$'...'` as the plain single-quoted string it stands for. It decodes `\n \t \r \a \b \e \f \v \\ \' \"`, `\xHH`, `\uHHHH`, `\UHHHHHHHH` and octal, and keeps an unknown escape as written, as bash does. An unterminated one is a parse error. Checked against real bash: `$'it\'s'` gives `it's`, `$'\x41'` gives `A`, and `"a $'b'"` stays literal. +- **Also**: the `form_string` branch added in U-20260923-52 had pushed `_apply_value_flag` to cyclomatic complexity 16, over the cap of 15. Its 15-way elif chain is now a `_VALUE_FLAG_HANDLERS` table with small named handlers, and the curl tests and fuzz tests pass unchanged. +- **Tests**: `test_curl_import.py`, `TestBashAnsiCQuoting`: + - a JSON body whose `\n` escape reaches bash as `\\n` is JSON; + - an escaped quote is a quote; + - eight escapes each give their character; + - `$'...'` inside double or single quotes is literal; + - an unterminated one is refused. +- **Result / numbers**: unit tests 1322 → 1334 passed, 14 skipped. `ruff check` clean, and every function is at or under complexity 15. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py` (530 lines) + - `test/test_utils/test_curl_import.py` + - `architecture_explore.md` §12, §18 + - `progress.md` (#32 removed) +- **Open items**: none. + +## U-20260923-55 · 2026-09-23 · Query null, repeated response headers and an out-of-range port are handled · #done #tools + +- **What**: `progress.md` #34, three small wrong outputs from the review: + - **JSON to query**: `{"a": null}` became `a=None`, Python's spelling. A nested object was URL-encoded as its Python repr (`%7B%27c%27...`). + - **Response inspector**: it kept only the last of repeated headers, so two `Set-Cookie` lines showed as one. + - **URL builder**: given a port out of range (`http://host:99999/`), it showed the parts without a port. A URL built back from them silently lost it. +- **Fix**: + - `_coerce_scalar` writes `null` as an empty value (`a=`). It refuses an object, or a list inside a list, with the new `nested_query_value_error`. + - The response inspector's headers map a repeated name to the list of its values (a `Set-Cookie` cannot be comma-joined), and the report prints one line per value. + - `url_to_json` reports the new `url_port_out_of_range_error` when the authority names a port `urlsplit` cannot read. `parse_url` keeps its documented tolerance, which returns `None` for such a port. +- **Rename**: the helper that keeps repeated values, added in U-20260923-53 as `curl_parser.add_query_value`, now also serves response headers. It is renamed `add_repeated_value`, and its docstring covers both uses. +- **Tests**: + - `test_query_convert.py`: `null` gives `a=`; an object, a nested list and an object in a list are refused. + - `test_url_convert.py`: port 99999 is reported; five URLs without a bad port still convert, including IPv6 with and without a port and a password containing `:`. + - `test_response_analyzer.py` and `test_response_inspector_gui.py`: two `Set-Cookie` lines are kept and both are printed. +- **Result / numbers**: unit tests 1334 → 1346 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/{query_tools/query_convert,url_tools/url_convert,response_inspector/response_analyzer,curl_import/curl_parser,har_import/har_parser}.py` + - `pybreeze/utils/exception/exception_tags.py` + - `pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py` + - the four matching tests + - `architecture_explore.md` §12, §18 + - `progress.md` (#34 removed) +- **Open items**: none. That clears the review's findings, #28 to #34. + +## U-20260923-56 · 2026-09-23 · The SSRF check also refuses IPv6 site-local addresses · #done #security + +- **What**: an audit of `url_validation._is_blocked_ip`. It is the SSRF check that CLAUDE.md § Security › Network requires before any request to a user-supplied URL. The audit compared its verdict with `ipaddress.is_global` over 34 special-purpose addresses on Python 3.11: + - IPv4: this-network, RFC 1918, CGNAT, loopback, link-local/metadata, the IETF block, TEST-NETs, benchmarking, multicast, reserved and broadcast. + - IPv6: loopback, unspecified, mapped/compatible, NAT64 (both prefixes), 6to4, Teredo, documentation, ULA, link-local, site-local, multicast and discard. + Wherever the two differed, the check was the stricter one, except for one range. `fec0::/10`, IPv6 site-local, deprecated but still routed inside some networks, passed both the check and `is_global`. +- **Fix**: an IPv6 address that `is_site_local` is refused. The docstring lists it. +- **Known limit, unchanged**: the check resolves the name, and the request that follows resolves it again. A hostile DNS server answering differently the second time (DNS rebinding) is not stopped by this check; stopping it would mean connecting to the address that was checked. +- **Tests**: `test_url_validation.py`: `http://[fec0::1]/` is blocked. +- **Result / numbers**: unit tests 1346 → 1347 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/utils/network/url_validation.py`, `test/test_utils/test_url_validation.py`, `architecture_explore.md` §18. +- **Open items**: none. + +## U-20260923-57 · 2026-09-23 · The AI review panel's accept/reject totals carry across sessions · #done #ai + +- **What**: the AI review panel records accepted and rejected answers in `~/.pybreeze/response_stats.txt`, stored under the home directory "so the data is stable". But the counts started at 0 in every new panel and the file was only ever written, never read. The first accept of a session wrote `Accepted: 1` over whatever total was there. +- **Fix**: new `read_stats(path)` reads the totals back when the panel opens, and counting carries on from them. The file is in the user's home, so it is read as untrusted. Only `Accepted: ` and `Rejected: ` lines with a non-negative whole number of fewer than 16 digits count. A missing, garbled or non-UTF-8 file counts as none yet and is logged at debug. +- **Tests**: `test_ai_code_review_client.py`: + - a panel opening on `Accepted: 7 / Rejected: 2` writes `Accepted: 8 / Rejected: 2` after one accept; + - five malformed files count as `(0, 0)`: empty, garbage, negative or non-numeric, 40 digits, no colon; + - a missing file and a binary file count as `(0, 0)`. +- **Result / numbers**: unit tests 1347 → 1354 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py`, `test/test_utils/test_ai_code_review_client.py`, `architecture_explore.md` §9, §18. +- **Open items**: none. + +## U-20260923-58 · 2026-09-23 · A diagram load either happens or leaves the canvas as it was · #done #diagram + +- **What**: `progress.md` #35, from the second review. A diagram image whose `source` was not a string (`"source": 5`, `null`, a list or an object) passed `DiagramImage.from_dict`, which stored it as is. It then raised `TypeError` in `_load_images`, on the path lookup after the per-item guard. By then `load_from_dict` had cleared the canvas, and `_open_diagram` kept the previously opened file as the save target. The next Save wrote the half-loaded canvas over the user's own file. U-20260923-10 had closed the whole-file version of this; this was the per-entry gap. +- **Fix**: + - `DiagramImage.from_dict` drops a `source` that is not text, and `_load_images` reads the item's cleaned `source()`. + - `load_from_dict` snapshots the canvas with `to_dict()` before clearing it. If the load still fails part-way, it puts the snapshot back and re-raises. A load now either happens or changes nothing, whatever the next unforeseen entry is. `test_no_blind_except` accepts this `except Exception`, because it re-raises. +- **Tests**: `test_diagram_serialization.py`: + - four non-text sources each load as an image with an empty source, keeping the rest of the diagram; + - a load made to fail inside `_load_images` leaves the previous canvas in place and raises. +- **Result / numbers**: unit tests 1354 → 1359 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items}.py` + - `test/test_utils/test_diagram_serialization.py` + - `architecture_explore.md` §7, §18 + - `progress.md` (#35 removed) +- **Open items**: none. + +## U-20260923-59 · 2026-09-23 · A hand-edited null no longer becomes the prthinker API key "None" · #done #prthinker #security + +- **What**: `progress.md` #36, from the second review. `prthinker_setting.load_setting()` took every known key from `~/.pybreeze/prthinker_setting.json` through `str()`. A hand-edited `null` therefore became the text `"None"`, which was sent to prthinker as `PRTHINKER_OPENAI_API_KEY=None`, `GITHUB_REPOSITORY=None` and `PRTHINKER_OPENAI_MODEL=None`. The review then failed against the backend with a misleading authentication error instead of "not configured". A list in `extra_arguments` became argv fragments such as `['[--a,', '--b]']`. +- **Fix**: every setting is text, so a value that is not a `str` keeps its default and is logged at debug. Unknown keys are still ignored, and a broken file still yields the defaults. +- **Tests**: `test_prthinker_setting.py`: `null`, a number, `true`, a list and an object each leave the API key and the extra arguments at their defaults, while a valid key beside them (`repository`) is still read. +- **Result / numbers**: unit tests 1359 → 1364 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `test/test_utils/test_prthinker_setting.py`, `architecture_explore.md` §18, `progress.md` (#36 removed). +- **Open items**: none. + +## U-20260923-60 · 2026-09-23 · Three menu actions that did nothing or raised now work · #done #menu + +- **What**: `progress.md` #39, from the second review. + - **Automation File › Create Project** called `safe_create_project("file_automation")`. The package is `automation_file`, as the same menu's run actions already use, so the import failed, a log line was written and nothing happened. + - **TestPioneer › Run YAML** accepted only `.yml`: an existing `run.yaml` was refused with "please choose a YAML file". The `Yaml (*.yml)` filter it set on a `QFileDialog` instance never applied either, because `getOpenFileName` was then called as a static method. + - **Run with…**: a plugin run config without `name` raised `KeyError` inside the slot after the file had been saved. JEditor does not validate what a plugin registers. A config without `compiler` raised the same way in `FileRunnerProcess.run_file`. +- **Fix**: + - Create Project names `automation_file`. + - TestPioneer accepts `.yml` and `.yaml` in any case (`_YAML_SUFFIXES`) and passes `YAML (*.yml *.yaml)` to the static dialog call. The module's imports now sit together above the `TYPE_CHECKING` block. + - The run window title uses `run_config.get("name", "Run")`. + - `run_file` reports "The run config names no compiler" in the run window instead of raising, and passes every `arg` as text. +- **Tests**: + - `test_automation_menu_factory.py`: every `safe_create_project("...")` in the automation menus names a package `importlib.util.find_spec` can find, checked without importing, since some of these packages write a log into the working directory when imported. + - New `test_testpioneer_menu.py`: `run.yml`, `run.yaml` and `RUN.YAML` run; the dialog gets both extensions; another file is refused with one message; cancelling does nothing. + - `test_plugin_menu.py`: a config without a name runs in a window titled `Run - main.go`; one without a compiler is reported. +- **Result / numbers**: unit tests 1364 → 1373 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/{automation_file_menu/build_automation_file_menu,test_pioneer_menu/build_test_pioneer_menu}.py` + - `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py` + - `pybreeze/extend/process_executor/file_runner_process.py` + - `test/test_utils/{test_automation_menu_factory,test_plugin_menu,test_testpioneer_menu (new)}.py` + - `architecture_explore.md` §18 + - `progress.md` (#39 removed) +- **Open items**: none. + +## U-20260923-61 · 2026-09-23 · A file can be renamed to a different case of its name on Windows · #done #editor + +- **What**: `progress.md` #40, from the second review. The file tree's Rename refused `main.py` → `Main.py` on Windows with "already exists". The filesystem there is case-insensitive, so `target.exists()` found the file being renamed. +- **Fix**: `_action_rename` refuses only when the target exists and is a different file. The new `_is_the_same_file()` uses `Path.samefile`, and a comparison that fails counts as different. A change of case is then an ordinary rename, and open tabs follow it as before. +- **Tests**: `test_file_tree_context_menu.py`: `main.py` renamed to `Main.py` gives no warning and leaves one file, `Main.py`, with the same content. Renaming onto a different existing file is still refused, by the existing test. +- **Result / numbers**: unit tests 1373 → 1374 passed, 14 skipped. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py`, `test/test_utils/test_file_tree_context_menu.py`, `architecture_explore.md` §18, `progress.md` (#40 removed). +- **Open items**: none. + +## U-20260923-62 · 2026-09-23 · Diagram editing: right-click, stacking, undo, sizes, wheel and teardown · #done #diagram + +- **What**: `progress.md` #37, eight editing faults from the second review, each reproduced there: + - **Right-click Delete** removed the *selected* item, not the one clicked: the view pans on a right press, so the scene never selected what was under the cursor. + - **Bring to Front / Send to Back** stepped z by 1, so a node at 0 stayed under one at 5. + - **Undo** turned overlapping nodes of equal z the other way up. `to_dict` listed items topmost first, and a restore re-added them in that order, putting the first-listed at the bottom. + - **Dragging an image** recorded no undo step: the Move snapshot started only for nodes. + - **Font size**: `font_size` from a file was not clamped. A 1000 pt label loaded as such, although the panel offers 6–48. + - **Property panel**: its width and height spinboxes stopped at 800 and 600, so changing only the height of a 1200-wide node shrank it to 800. Image sides stopped at 2000. + - **Wheel**: a sideways wheel or trackpad swipe (`angleDelta().y() == 0`) zoomed out. + - **Teardown**: deleting the editor with a node selected raised `RuntimeError` in the panel's `selectionChanged` slot, which the dying scene still emits. +- **Fix**: + - A right-click on an item outside the selection makes it the selection first. + - `_change_z` places the selected nodes above the highest, or below the lowest, of the others, keeping their order among themselves. + - `get_all_*` list items bottom-first (`_bottom_first()`), so saves and restores keep the stacking. + - A press on a selected image starts the Move undo step. + - `_clamped_font_size()` applies the panel's range (`MIN_FONT_SIZE`/`MAX_FONT_SIZE`, now shared with the panel) when a node is built and when the size is set. + - The panel's node and image sides range up to `MAX_ITEM_SIZE`. + - The view passes a sideways wheel on instead of zooming. + - The panel's slot returns when `shiboken6.isValid(scene)` is false. +- **Tests**: new `test_diagram_editing.py`, 12 tests: + - right-click Delete removes the clicked node, and a click inside a multi-selection keeps it; + - front and back go past every other node, and back keeps the selected nodes' order; + - undo keeps equal-z overlap the same way up; + - a press on a selected image opens a Move step; + - three font sizes are clamped (1000 to 48, 1 to 6, 12.7 to 12); + - the panel shows a 1200×900 node as is; + - the slot survives a deleted scene; + - a sideways wheel does not zoom and a vertical one does. +- **Result / numbers**: unit tests 1386 passed, 14 skipped. `ruff check` clean, and every function is under complexity 15. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/{diagram_scene,diagram_items,diagram_property_panel,diagram_view}.py` + - `test/test_utils/test_diagram_editing.py` (new) + - `architecture_explore.md` §7, §18 + - `progress.md` (#37 removed) +- **Open items**: none. + +## U-20260923-63 · 2026-09-23 · Mermaid import: syntax inside labels, bare headers, directions and more links · #done #diagram + +- **What**: `progress.md` #38, Mermaid import faults from the second review: + - arrows and `;` were split out of the raw text, so `A["a --> b"]-->B` became three nodes and two edges, and `A["a;b"]-->B` lost A's label; + - a `graph` or `flowchart` header with no direction became a node called "graph"; + - `direction LR` inside a subgraph became a node called "direction"; + - text on `---`, `--o` and `--x` links became a node, and `A ~~~ B` dropped B. +- **Fix**: + - `_protect()` replaces every quoted or bracketed segment with a placeholder before anything is split, whether by `;` in `_parse_lines` or by arrow in `_parse_statement`. Brackets nest, and a quote inside brackets hides its brackets. `_restore()` puts each segment back as a node or arrow is parsed. + - The header's direction is optional, defaulting to TD, and needs a word boundary, so a node named `graphics` is still a node. + - `direction ` is skipped like `subgraph` and `end`. + - Text on any normal link (`-- t -->`, `-- t ---`, `-- t --o`, `-- t --x`) is the link's label. + - `~~~` is a link body that registers both nodes and draws no connection, as in Mermaid, where it only affects placement. +- **Tests**: `test_mermaid_parser.py`: + - four labels holding `-->`, `;`, `--` text or `~~~`; + - a bracket inside a quoted label; + - three direction-less headers; + - `direction` inside a subgraph; + - node names that start like keywords; + - four labelled link forms; + - an invisible link. + The existing 54 Mermaid tests and the fuzz tests pass unchanged. +- **Result / numbers**: unit tests 1401 passed, 14 skipped. `ruff check` clean, and every function is under complexity 15. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py` (603 lines) + - `test/test_utils/test_mermaid_parser.py` + - `architecture_explore.md` §7, §18 + - `progress.md` (#38 removed) +- **Open items**: none. + +## U-20260923-64 · 2026-09-23 · Diagram editor shortcuts act only while it has focus · #fix #diagram + +- **What**: opened as a dock, the diagram editor shares the main window with the code editor, and its shortcuts (Ctrl+Z, Ctrl+Y, Ctrl+C, Ctrl+V, Ctrl+D, Ctrl+=, Ctrl+-, Delete, ...) were window-wide. Where the code editor had no action on the key, the diagram took it, so Ctrl+D in the code editor duplicated a diagram node. Where both bound the same key, Qt found the shortcut ambiguous and neither ran. +- **Fix**: `_bind_shortcuts` gives every shortcut the `WidgetWithChildrenShortcut` context, so it fires only while focus is in the diagram editor (canvas, panel or toolbars). +- **Tests**: `test_diagram_editor_widget.py::TestShortcuts` checks that every shortcut of the editor has that context. +- **Result / numbers**: unit tests 1402 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` (624 lines) + - `test/test_utils/test_diagram_editor_widget.py` + - `architecture_explore.md` §7, §18 +- **Open items**: none. + +## U-20260923-65 · 2026-09-23 · Prompt editor: no saved 'does not exist' note, no lost edits on switch · #fix #ai + +- **What**: two faults in the prompt editor shared by the CoT and Skill template editors (`prompt_editor_widget.py`), both reproduced headless: + - For a template with no file yet, the edit area's *text* was the note "(File linter.md does not exist)". Save wrote that note to disk, and since `load_prompt` treats any non-empty file as an override, every later review sent the note as the prompt, with no `{code_diff}` in it, so no code reached the model. + - Choosing another template in the selector, or pressing Reload, replaced unsaved edits without asking. Only a change made on disk asked first. +- **Fix**: + - The note is the edit area's placeholder, and the text is empty. Saving that writes an empty file, which `load_prompt` already treats as "use the built-in". + - `_may_discard_edits()` asks before unsaved edits are lost, for a template switch, Reload and a change on disk alike. Answering No on a switch puts the selector back without reloading. + - New language keys `prompt_editor_switch_over_edits` and `prompt_editor_reload_button_over_edits`, in English and Traditional Chinese. +- **Tests**: `test_prompt_store.py`: + - saving a template that has no file leaves the built-in in use; + - keeping edits on a switch stays on the template with the edits; + - letting them go switches; + - Reload with edits asks and keeps them on No. +- **Result / numbers**: unit tests 1406 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_editor_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_prompt_store.py` + - `architecture_explore.md` §8, §18 +- **Open items**: none. + +## U-20260923-66 · 2026-09-23 · AI panels: no URLs in logs, damaged urls.txt, shared vote totals, prompt fields · #fix #ai #security + +- **What**: faults found by a review of `extend_ai_gui/` and `connect_gui/url/`, each reproduced headless: + - **URLs in the log**: the CoT chain (`code_review_thread.py`) and the Skill sender (`skills_send_gui.py`) logged a failed request with `%r`. A `requests` error names the whole URL ("Max retries exceeded with url: /x?token=..."), and an API URL may carry a token. The AI review client already logged only the error's type. + - **A damaged `urls.txt`**: `record_url` read `~/.pybreeze/urls.txt` without a guard. A file that is not UTF-8, or is locked, raised from the Send slot on every click, so no request could be sent. + - **Two review panels open at once**: each counted votes on from the totals it read when it opened. The second one to save wrote the first one's votes away (5/5 on disk; 3 accepts in A, then 1 reject in B, saved 5/6). + - **An edited CoT prompt that reaches into a placeholder**: `{code_diff[x]}` raised a `TypeError` that the fallback did not catch, which ended the whole chain silently. `{code_diff.upper}` sent ``, and `{code_diff[0]}` sent one character of the code. +- **Fix**: + - Both threads log only `type(error).__name__`. + - `_read_recorded_lines()` treats an unreadable file as empty, and a failed write of the file is logged at debug level; the request goes out either way. + - `_count_verdict()` re-reads the totals from disk before adding a vote. + - `cot_chain._fill()` refuses any field that is not a bare name, which sends the step to the built-in template, and the fallback also catches `TypeError`. Escaped braces and format specs still work. +- **Tests**: + - `test_ai_code_review_client.py`: a damaged file, a file that cannot be written, two panels voting. + - `test_skills_request.py` and `test_cot_session_reuse.py`: a failed request does not log the URL's token. + - `test_prompt_store.py`: five field forms fall back to the built-in, and escapes and specs still fill. +- **Result / numbers**: unit tests 1417 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/{cot_chain,code_review_thread}.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py` + - `test/test_utils/{test_ai_code_review_client,test_skills_request,test_cot_session_reuse,test_prompt_store}.py` + - `architecture_explore.md` §18 +- **Open items**: + - `CoTCodeReviewGUI` cannot be opened from any menu. + - Its URL check resolves DNS on the UI thread. + - Its answers carry over from one run to the next. + +## U-20260923-67 · 2026-09-23 · CoT code review panel: in the menus, no DNS on the UI thread, fresh runs · #done #ai + +- **What**: `progress.md` #41. The CoT code review panel (`CoTCodeReviewGUI`), which sends code through the chain the CoT prompt editor edits, had no entry in any menu, so it could not be opened. It also had two faults: + - it checked the URL on the UI thread before the worker checked it again, and that check's DNS lookup froze the IDE for as long as the resolver took; + - its answers and response selector were never cleared, so a failed second run's `error` sat beside answers about the previous code. +- **Fix**: + - A `CoTCodeReview` row in `_WIDGET_FACTORIES`, `_TAB_ACTIONS`, `_DOCK_ACTIONS` and `_DOCK_TITLES`, so it opens from **Tools → AI** as a tab and from the Dock menu's AI submenu. Language keys `extend_tools_menu_cot_code_review_*` in English and Traditional Chinese. + - The panel leaves the URL check to the worker, which reports a refused URL as the `error` answer. + - Every Send clears the answers, the selector and the view first. + - README: the chain's eight steps listed as they run, and where to run it. +- **Tests**: + - `test_tools_menu_docks.py`: the dock builds the panel, and every widget in `_WIDGET_FACTORIES` has a tab, a dock and a dock title, so a widget with no menu entry cannot happen again. + - `test_cot_session_reuse.py`: Send resolves nothing on the UI thread, and a new run clears the last run's answers. +- **Result / numbers**: unit tests 1421 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/tools/tools_menu.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/code_review/cot_code_review_gui.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_tools_menu_docks.py`, `test_cot_session_reuse.py` + - `README.md` + - `architecture_explore.md` §5.3, §8, §18 + - `progress.md` (#41 removed) +- **Open items**: none. + +## U-20260923-68 · 2026-09-23 · Run with: a save that fails is reported and runs nothing · #fix #plugins + +- **What**: a review of `menu/` and `editor_main/` found that Run with... (and the Plugins menu's Run entries, which share `save_current_file_for_run`) did not handle a failed save. Reproduced headless with a Big5 tab holding a character Big5 cannot encode; a read-only or locked file fails the same way. + - `write_file_with_encoding` raised `JEditorSaveFileException` out of the menu slot. Nothing ran, and nothing said why. + - `mark_ignore_next_file_change()` had been called before the write, so the tab's flag stayed on and swallowed the next real change made to the file outside the editor. +- **Fix**: + - The save is in `_save()`. `save_current_file_for_run` catches `JEditorSaveFileException`, logs the cause, warns with the file name and the reason (new language key `run_with_save_failed`), and returns `None`, so nothing runs. + - The tab is told to expect the write only after the write has happened. The watcher's signal is queued, so it still arrives after the flag is set. +- **Tests**: `test_plugin_menu.py::TestASaveThatFailsBeforeARun`: a Big5 tab holding "€" is reported, returns no path, and leaves the tab's flag alone. The existing save tests pass unchanged. +- **Result / numbers**: unit tests 1422 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_plugin_menu.py` + - `architecture_explore.md` §5.4, §18 + - `progress.md` (#42–#48, the review's other findings) +- **Open items**: + - #42–#48 in `progress.md`. + - In JEditor, `write_file_with_encoding` opens the file with `"w"` before encoding, so an encoding failure leaves it empty on disk (JEDITOR repo). + +## U-20260923-69 · 2026-09-23 · AutoControl Record Stop: stops from any tab, inserts runnable JSON · #done #autocontrol + +- **What**: `progress.md` #42, AutoControl's Record Stop (`build_autocontrol_menu.stop_record`): + - It called `je_auto_control.stop_record()` only when the tab in front was an editor. From a tool tab, a browser tab or JupyterLab, nothing happened and the global keyboard and mouse hooks kept recording. + - It inserted `str(actions)`, a Python repr with single quotes. The AutoControl runner reads a script with `json.loads`, so the inserted recording could not be run. + - With nothing recorded, or recording never started, `stop_record()` returns `None`, and the text `None` was inserted. +- **Fix**: + - Recording always stops first. + - The actions are inserted as `json.dumps` at the cursor of the editor tab in front. With no editor tab in front they go on the clipboard, and the user is told. + - With nothing recorded the user is told and nothing is inserted. + - New language keys `autocontrol_record_nothing` and `autocontrol_record_copied`. The Traditional Chinese labels of the Record menu read 錄製 / 開始錄製 / 停止錄製 instead of the half-English "紀錄 Start" / "紀錄 Stop". +- **Tests**: new `test_autocontrol_record.py`: + - the recording goes in as JSON that `json.loads` reads back; + - a tool tab in front still stops recording, and the recording goes to the clipboard; + - `None` and an empty recording insert nothing and say so. +- **Result / numbers**: unit tests 1426 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/auto_control_menu/build_autocontrol_menu.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_autocontrol_record.py` (new) + - `architecture_explore.md` §5.1, §18 + - `progress.md` (#42 removed) +- **Open items**: none. + +## U-20260923-70 · 2026-09-23 · TestPioneer Create template: in the working directory, asks before replacing · #done #testpioneer + +- **What**: `progress.md` #43, TestPioneer's Create template called `test_pioneer.create_template_dir()` with no arguments: + - It wrote under the process's current directory, not the IDE's working directory. The two are usually the same, since JEditor changes into a folder it opens, but not always. + - TestPioneer rewrites `.TestPioneer/.TestPioneer.yml` whenever the folder exists, so a second click wiped an edited template without asking. + - A write that failed raised `ProjectException` (or `OSError`, from creating the folder) out of the menu slot. +- **Fix**: `create_template(main_window)`: + - It writes to the main window's `working_dir`, falling back to the current directory. + - When the template exists, it asks before replacing it, defaulting to No. + - A failure is logged and shown as a warning. TestPioneer does not export `ProjectException`, a bare `Exception` subclass, so the handler catches `Exception` with a `# noqa: BLE001` reason. + - A success names the file created. + - New language keys `test_pioneer_template_exists`, `_failed` and `_created`. +- **Tests**: new `test_test_pioneer_template.py`: + - it goes in the working directory, not the current one; + - an edited template survives a No; + - a Yes replaces it; + - a failed write is reported, not raised. +- **Result / numbers**: unit tests 1430 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py` + - `test/test_utils/test_test_pioneer_template.py` (new) + - The three language keys went into both language files in the commit of U-20260923-69. + - `architecture_explore.md` §5.2, §18 + - `progress.md` (#43 removed) +- **Open items**: none. + +## U-20260923-71 · 2026-09-23 · prthinker Review current file saves the file first · #done #prthinker + +- **What**: `progress.md` #45. prthinker's Review current file reads the file from disk. A tab with unsaved edits passed the "is it a file" check, so the review ran on the last saved version, with nothing said and no save offered. +- **Fix**: `_review_current_file` saves first with `save_current_file_for_run()`, the same helper Run with... uses: the tab's own encoding and line ending, Save As for a tab with no file, and a warning when the save fails. A cancelled Save As reviews nothing. A tab that is not an editor still gets the "needs a saved file" message. +- **Tests**: new `test_prthinker_menu_review.py`: + - the file is saved before it is reviewed; + - a save that did not happen reviews nothing and adds no second message; + - a tool tab gets the message. +- **Result / numbers**: unit tests 1433 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/prthinker_menu/build_prthinker_menu.py` + - `test/test_utils/test_prthinker_menu_review.py` (new) + - `architecture_explore.md` §5.2, §18 + - `progress.md` (#45 removed) +- **Open items**: none. + +## U-20260923-72 · 2026-09-23 · File tree Delete closes only the tabs whose files are gone · #done #filetree + +- **What**: `progress.md` #44. File tree Delete closed every tab open on the file, or under the folder, with `editor.close()` and `removeTab`, and only then deleted. When the delete failed, the file stayed on disk but its tab and any unsaved edits were gone. Deleting a folder could also stop part-way. Causes: a file locked by another program on Windows, a read-only file, a file in use. +- **Fix**: `_action_delete` stops the tabs' auto-save first with `_stop_auto_save()`, as rename does, so no buffer is written back mid-delete. Then it deletes. Afterwards only a tab whose file no longer exists is closed; a tab whose file survived gets its auto-save back with `_start_auto_save()`. The watcher's change signal for a file that is gone is ignored by JEditor, so no reload question appears. +- **Tests**: `test_file_tree_context_menu.py::TestDeleting::test_a_delete_that_fails_keeps_the_tab_open`: `unlink` is refused, the file stays, the tab stays open and its auto-save restarts. The existing delete tests pass unchanged. +- **Result / numbers**: unit tests 1434 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py` + - `test/test_utils/test_file_tree_context_menu.py` + - `architecture_explore.md` §3, §18 + - `progress.md` (#44 removed) +- **Open items**: none. + +## U-20260923-73 · 2026-09-23 · Closing the IDE survives a tab, dock or run window that raises · #done #lifecycle + +- **What**: `progress.md` #46. `PyBreezeMainWindow.closeEvent` stops and closes each run window, then closes every tool tab and dock, then runs JEditor's own `closeEvent`. Nothing guarded those closes, and some of the widgets are third-party (`EDITOR_EXTEND_TAB`) or plugin widgets. When one raised, the rest were never closed. JEditor's close never ran either, so the open files were not recorded, the user settings not written and the editors' auto-save threads not stopped. +- **Fix**: `_close_guarded(widget, *steps)` runs each closing step and logs a step that raises (`# noqa: BLE001` with the reason), then goes on to the next. The run windows' `stop_runner`/`close` and every tab's and dock's `close` go through it. +- **Also**: `progress.md` #47 is no longer a decision. Closing a docked JEditor editor is meant to save it, and the damage came from JEditor's dock: it saved when nothing was edited, in UTF-8 with platform line endings. That is fixed in JEditor (JEDITOR U-20260923-07), so #47 now waits on the next `je_editor` release. +- **Tests**: new `test_main_window_close_guard.py`: + - a failing step is logged and the next step still runs; + - a tool tab that raises on close leaves the tabs before and after it closed. +- **Result / numbers**: unit tests 1436 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/editor_main/main_ui.py` + - `test/test_utils/test_main_window_close_guard.py` (new) + - `architecture_explore.md` §4, §18 + - `progress.md` (#46 removed, #47 reworded) +- **Open items**: #47 [BLOCKED] on the `je_editor` release. + +## U-20260923-74 · 2026-09-23 · Install prthinker: a parented dialog, and only prthinker's source is kept · #done #prthinker + +- **What**: `progress.md` #48, Install prthinker (`build_automation_install_menu.install_prthinker`): + - It called the static `QFileDialog.getExistingDirectory` through a new instance, `QFileDialog(parent=...).getExistingDirectory(caption=...)`. The parent went to the unused instance, so the folder dialog had no parent and could open behind the main window. + - `install_target()` accepted any existing folder. A wrong pick was saved as prthinker's source before pip ran, and every later click went straight to a pip that failed. It could only be cleared in the prthinker settings dialog. +- **Fix**: + - The dialog is called statically with the main window as parent. + - `install_target()` requires the folder's `pyproject.toml` to name the project `prthinker`. The name is matched by a regular expression: `tomllib` is 3.11+, and the IDE supports 3.10. A folder that fails the check is neither saved nor installed, and the user gets the existing "choose prthinker's source" message. A saved folder that no longer qualifies falls back to asking. + - Checked against the real prthinker checkout in the workspace, which qualifies; the PyBreeze folder does not. +- **Tests**: + - `test_prthinker_setting.py`: the source folder qualifies; any other folder, and a project with another name, does not. + - `test_install_menu.py`: a remembered source installs; a folder that is not prthinker's is not saved and starts no pip. +- **Result / numbers**: unit tests 1438 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/prthinker_extend/prthinker_setting.py` + - `pybreeze/pybreeze_ui/menu/install_menu/automation_menu/build_automation_install_menu.py` + - `test/test_utils/test_prthinker_setting.py`, `test_install_menu.py` + - `architecture_explore.md` §14, §18 + - `progress.md` (#48 removed) +- **Open items**: none. + +## U-20260923-75 · 2026-09-23 · AI review panel: Send always comes back, and only an answer can be voted on · #fix #ai + +- **What**: two faults in the AI code review panel (`connect_gui/url/ai_code_review_gui.py`), found in the review sweep: + - Send was re-enabled only from the `answered` and `failed` slots. A request thread that ended any other way emitted neither, and Send stayed greyed out until the panel was reopened. + - Accept and Reject were always enabled. A vote was counted with an empty panel, after an error, or several times for one answer, and the totals in `~/.pybreeze/response_stats.txt` drifted. +- **Fix**: + - Send comes back from the thread's `finished` signal. `let_run_out` already cuts `finished` off when the panel closes first. + - Accept and Reject start disabled, are enabled by an answer, and are disabled again by a vote or a new Send. + - A non-2xx status (a redirect that is not followed included) is now a `failed`, so an error page cannot be voted on. The panel shows it as an error with the status line. +- **Tests**: `test_ai_code_review_client.py`: + - the two status tests now expect `failed`; + - no vote before an answer; one vote per answer; no vote on a failure; + - Send comes back after a request that emitted nothing. +- **Result / numbers**: unit tests 1442 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/url/ai_code_review_gui.py` + - `test/test_utils/test_ai_code_review_client.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-76 · 2026-09-23 · Run a folder: a parented dialog, a message when nothing matches, and a Qt-free utils · #fix #executor #refactor + +- **What**: the "run every file in a folder" actions (`run_dir_files_with_package`, used by each automation menu's multi-file entries) asked for the folder through `utils/file_process/get_dir_file_list.ask_and_get_dir_files_as_list`: + - it called the static `getExistingDirectory` through a throwaway `QFileDialog(parent=main_window)`, so the dialog had no parent and could open behind the IDE (the same fault as #48); + - a folder with no `.json` in it did nothing, with no word to the user; + - it was the only Qt import in `utils/`, which is meant to be pure logic. +- **Fix**: + - The wrapper is gone from `utils/`. `process_executor_utils._ask_for_action_files()` opens the dialog statically with the main window as parent. When the folder holds no action JSON it says so, in a message box with the new `run_folder_title`/`run_folder_no_action_files` keys (English and Traditional Chinese). + - `get_dir_files_as_list()` is unchanged. +- **Tests**: + - new `test_run_folder.py`: the dialog's parent is the main window; cancelling is silent; nested JSON files are found and other files skipped; an empty folder is reported. + - new `test_utils_has_no_qt.py`: no module under `pybreeze/utils/` imports PySide6 or je_editor. +- **Result / numbers**: unit tests 1447 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/process_executor_utils.py` + - `pybreeze/utils/file_process/get_dir_file_list.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_run_folder.py`, `test_utils_has_no_qt.py` (new) + - `architecture.md` §2 table, `architecture_explore.md` §4.2, §12, §18 +- **Open items**: none. + +## U-20260923-77 · 2026-09-23 · SFTP: a session that comes up after Disconnect or tab close is closed · #fix #ssh + +- **What**: the SFTP file tree's connect (`connect_gui/ssh/ssh_file_viewer_widget.py`, `SFTPClientWrapper.connect`) runs on the connect thread and kept the client only in `self._ssh`, which Disconnect and tab close set to `None` from the UI thread. While the TCP connect runs (up to 10 s), `SSHClient.close()` does nothing, so the connect went on and logged in. Then `self._ssh.get_transport()` raised `AttributeError`, which `SshConnectThread` does not catch. Neither `connected` nor `failed` was emitted, the status was not updated, and an authenticated session stayed open, sending keepalives, until the IDE exited. Found in the review sweep of `connect_gui/ssh/`. +- **Fix**: + - `connect()` works on its own client in a local variable. Once SFTP is open, it keeps the session only if the wrapper still holds that client; otherwise it closes it and raises `ConnectAbandoned` (a `RuntimeError`, so the thread reports it as a failure). On any failure it closes only its own client, and clears `self._ssh` only if that is still its own. + - The login is split out as `_log_in()`. + - `_disconnect()` while a connect is running hands the thread to `let_run_out()`. A Disconnect the user asked for no longer ends in a "connection failed" box, and Connect can be clicked again at once. +- **Tests**: `test_ssh_reentrancy.py::TestTheRealWrapperGivenUpOn`, using the real wrapper with a stand-in paramiko client: + - a session that comes up after `close()` is closed and not kept; + - a connect left alone is kept; + - Disconnect while connecting shows no failure and frees Connect. +- **Result / numbers**: unit tests 1450 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `test/test_utils/test_ssh_reentrancy.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none here. The same sweep's other SSH findings are taken up one by one. + +## U-20260923-78 · 2026-09-23 · A run window closed mid-run is let go of when its run ends · #fix #executor + +- **What**: a run window (`show_code_window/code_window.py`) emits `finished_and_closed`, which lets the main window drop it from `current_run_code_window`, only from `closeEvent` and only when nothing is running. A window closed while its program still ran never emitted it. The window stayed in the list for the rest of the session, with its executor, queues, timer and up to 10,000 lines of output: the growth the signal was added to stop. Found in the review sweep of `show_code_window/`. +- **Fix**: + - `closeEvent` records `_closed_while_running` instead. + - New `CodeWindow.run_ended()`: when that flag is set, it emits `finished_and_closed` through `QTimer.singleShot(0, ...)`. The list may hold the window's last reference, and the executor's timer, whose slot is still on the stack, is the window's child. + - `TaskProcessManager.exit_program()` calls it last, after the task-done hook. `FileRunnerProcess._finish()` calls it after its output and binary clean-up, and after a failed compile that starts nothing next. +- **Tests**: `test_code_window.py`: + - a window closed mid-run is kept while the run goes on; + - it is let go of after the run ends, not synchronously; + - a window still open is not let go of; + - `FileRunnerProcess` calls `run_ended` after a real run. +- **Result / numbers**: unit tests 1454 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/show_code_window/code_window.py` + - `pybreeze/extend/process_executor/python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_code_window.py` + - `architecture_explore.md` §4.2, §18 +- **Open items**: none. #2 (whether closing a run window should stop its run) is still the owner's decision. + +## U-20260923-79 · 2026-09-23 · JupyterLab tab says why it did not start; a closed tab is no failure · #fix #jupyter + +- **What**: two faults in the JupyterLab tab (`jupyter_lab_gui/`), found in the review sweep: + - Whatever stopped the lab showed only "JupyterLab init failed": no venv found, a failed `pip install`, a timeout, or the server exiting early. The reason, including the output tail `_wait_until_ready` collects for it, went to the log only. + - Closing the tab while the server started logged a false error. `stop()` ends the server, the wait then sees it exit, and the launcher logged "JupyterLab launch failed" with a traceback. +- **Fix**: + - `JupyterLauncherThread.run()` emits the exception's message (its last 2,000 characters, since pip's stderr can be long) and logs the traceback with lazy formatting instead of an f-string. A failure after `stop()` is logged at debug level and not reported. + - `JupyterLabWidget.show_error()` shows "init failed: " as plain text (`Qt.TextFormat.PlainText`: the output is not markup), word-wrapped and selectable, and no longer logs the message a second time. +- **Tests**: `test_jupyter_ready.py::TestWhatAFailureShows`: + - the reason reaches the tab and the traceback goes only to the log; + - a failure after the tab closed is neither reported nor logged as an error; + - the tab shows the reason as plain text. +- **Result / numbers**: unit tests 1457 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `jupyter_lab_widget.py` + - `test/test_utils/test_jupyter_ready.py` + - `architecture_explore.md` §10, §18 +- **Open items**: none. + +## U-20260923-80 · 2026-09-23 · SSH shell ended by the server closes its session and reports both halves · #fix #ssh + +- **What**: when the shell ended on the server's side (`exit`, a dropped link), `SSHCommandWidget._on_closed` (`connect_gui/ssh/ssh_command_widget.py`) only printed a line and wrote "disconnected" into the status label. The SSH client and its transport stayed open, sending keepalives, until the next Connect, Disconnect or tab close. `state_changed` was not emitted either, and the label is shared with the file tree, so a file tree that was still connected was shown as disconnected. Found in the review sweep of `connect_gui/ssh/`. +- **Fix**: `_on_closed` does what Disconnect does: `_cleanup()` closes the reader, channel and client, then it emits `state_changed`, and the combined view reports both halves again. +- **Tests**: `test_ssh_teardown.py::TestTheShellEndingOnTheServer`: + - the channel and the client are closed; + - with the file tree still connected, the shared label says "files only". +- **Result / numbers**: unit tests 1459 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py` + - `test/test_utils/test_ssh_teardown.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-81 · 2026-09-23 · Unknown SSH host key: asked once per Connect, and no accepted host written away · #fix #ssh #security + +- **What**: the SSH host key policy (`connect_gui/ssh/ssh_host_key_policy.py`, `InteractiveHostKeyPolicy.missing_host_key`), found in the review sweep: + - One Connect in the SSH tab starts two connects, the shell's and the file tree's. Each client loaded `~/.pybreeze/ssh_known_hosts` at the click, so a new host brought up two identical fingerprint questions, the second on top of the first. + - Accepting saved with `client.save_host_keys()`, which writes the client's own copy of the file as read at its Connect. Two tabs accepting two new hosts at the same time wrote one of them away, and that host was asked about again next time. +- **Fix**: + - The lookup, question and save run one at a time under a module-level lock. Only connect threads take it, since both widgets connect off the UI thread, so the UI thread showing the question never waits on it. + - Before asking, the file is read again; a key the other half has just accepted is taken without a question. + - A "no" is remembered for 10 seconds per (host, fingerprint), so the other half is refused without asking again. A deliberate retry later is asked as usual. + - Saving reads the file as it is now and adds the key (`_store()`). + - A different key for a known host still reaches the question. +- **Tests**: new `test_ssh_host_key_policy.py`, with real paramiko RSA keys: + - both halves of one Connect ask once; + - a "no" is not asked again by the other half; + - two tabs accepting two hosts keep both in the file; + - another key for a known host is still asked about; + - questions from two threads come one at a time. +- **Result / numbers**: unit tests 1464 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py` + - `test/test_utils/test_ssh_host_key_policy.py` (new) + - `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-82 · 2026-09-23 · SFTP: one request at a time on the session, and a download replaces its file only when complete · #fix #ssh + +- **What**: two faults in the SFTP file tree (`connect_gui/ssh/ssh_file_viewer_widget.py`, `SFTPClientWrapper`), found in the review sweep: + - The session was used from several places at once. Every folder expand starts its own `SftpListThread`, a transfer runs beside them, and Create folder, Rename and Delete call the session from the UI thread. paramiko's `SFTPClient._read_response` throws away a reply that the wrong thread reads, and the thread that sent that request then waits forever, since an SFTP channel has no read timeout. On a slow link, expanding two folders quickly could leave one on "Loading…" for good, and Create folder during a download could freeze the IDE. + - A download called `sftp.get(remote, local)`, which empties the local file before the first byte arrives. A dropped link, or a Disconnect during the transfer, left the file the user had chosen to replace cut short. +- **Fix**: + - Every request goes through `_session(wait)`, which holds `_in_use` (a `threading.Lock`) for one request and checks the connection under it. Listings and transfers run on worker threads and wait their turn. The menu's calls on the UI thread wait at most `UI_WAIT_SECONDS` (1 s), then raise `SftpBusy`, a `RuntimeError` that the context menu's existing handler shows as "The SFTP session is busy…" (new key `ssh_file_viewer_message_session_busy`, English and Traditional Chinese). + - `download()` writes to a temporary file beside the target (`tempfile.mkstemp(..., suffix=".part")`) and moves it into place with `os.replace` only when complete. On any failure the partial file is deleted and the original is untouched. + - The unused `is_dir()` is removed. +- **Tests**: new `test_sftp_session_sharing.py`: + - two listings take turns; + - a menu action during a transfer is refused, not frozen, and the two never overlap; + - a menu action on a free session goes through; + - a download replaces the file only when complete and leaves no partial file; + - a download that fails leaves the old file whole. +- **Result / numbers**: unit tests 1469 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_sftp_session_sharing.py` (new) + - `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-83 · 2026-09-23 · Epoch values and JWT claims before 1970 convert on Windows · #fix #tools + +- **What**: on Windows, the Timestamp tool rejected any epoch value more than about 12 hours before 1970. `convert_timestamp("-86400")` reported "not a recognized epoch number", while the ISO form of the same instant converted. `datetime.fromtimestamp(v, tz=utc)` goes through the C runtime, which raises `OSError 22` there. The JWT decoder's `format_timestamp_claim` used the same call, so a claim before 1970 was silently left out of the readable times. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: new `utc_from_epoch_seconds()` (`utils/timestamp_tools/timestamp_converter.py`) computes the Unix epoch plus a `timedelta`, which works on every platform. It raises `OverflowError` outside the years `datetime` holds and `ValueError` for NaN. The converter and `jwt_decoder.format_timestamp_claim` both use it. +- **Tests**: + - `test_timestamp_converter.py`: a day before 1970 and 1960-01-01 convert. + - `test_jwt_decoder.py`: a claim before 1970 is shown; NaN is not a timestamp. +- **Result / numbers**: unit tests 1473 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/timestamp_tools/timestamp_converter.py` + - `pybreeze/utils/jwt_tools/jwt_decoder.py` + - `test/test_utils/test_timestamp_converter.py`, `test_jwt_decoder.py` + - `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-84 · 2026-09-23 · Diff shows a change when only a final newline or a line ending differs · #fix #tools + +- **What**: the Diff tool (`utils/diff_tools/text_diff.py`) compared lines without their endings but decided "identical" on the raw strings. For `"a\n"` against `"a"` it said the texts differ, then showed "+0 / -0" and an empty diff. The same happened for `\r\n` against `\n`. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: `_line_lists()` compares lines without their endings, as before. Only when that finds no difference while the texts still differ does it keep the real endings. The shown diff then marks an added or removed line with no newline with diff's own `\ No newline at end of file`, and names any other ending (`(line ends with '\r\n')`). All other results are unchanged: `"a"` against `"a\nb"` is still one added line. +- **Tests**: `test_text_diff.py::TestOnlyTheLineEndingsDiffer`: + - a missing final newline shows as a change; + - a different line ending is named; + - other changes count as before. +- **Result / numbers**: unit tests 1476 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/diff_tools/text_diff.py` + - `test/test_utils/test_text_diff.py` + - `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-85 · 2026-09-23 · A tool's Save to file says when the file was not saved · #fix #tools + +- **What**: "Save to file" under every tool's output (`tools_gui/output_actions.py`, `OutputActions.save_to_file`) caught an `OSError`, logged it and returned. A save into a read-only folder or over a locked file showed nothing, so the user took the file as saved. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: the failure is shown in a warning with the file name and the reason (`strerror`, not the full path). New keys `output_actions_save_failed_title` and `output_actions_save_failed_message`, in English and Traditional Chinese. +- **Tests**: `test_output_actions.py::TestSaveToFile::test_a_failed_write_is_reported`. +- **Result / numbers**: unit tests 1477 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/tools_gui/output_actions.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_output_actions.py` + - `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-86 · 2026-09-23 · A diagram file with an infinite or non-numeric font size opens · #fix #diagram + +- **What**: a `.diagram.json` whose `font_size` was `1e999` could not be opened, and nothing said why. `json.loads` reads `1e999` as infinity, and `int(inf)` in `_clamped_font_size` (`diagram_editor/diagram_items.py`) raises `OverflowError`. Neither `_load_nodes` nor `_open_diagram` catches that (they catch `ValueError`, `TypeError`, `KeyError` and `OSError`), so it left the Qt slot. The canvas was put back, and the user saw nothing. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: `_clamped_font_size` catches `OverflowError`, `TypeError` and `ValueError` and uses the default label size (10). Infinity, NaN, a string or `null` in the file now open with the default size instead of failing. +- **Tests**: `test_diagram_editing.py::test_a_font_size_from_a_file_is_kept_in_range` gains infinity, NaN, a string and `None`. +- **Result / numbers**: unit tests 1481 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py` + - `test/test_utils/test_diagram_editing.py` + - `architecture_explore.md` §7, §18 +- **Open items**: the same sweep found that text typed on the canvas is not recorded for undo, and that undo blanks images the editor cannot reload; recorded in `progress.md`. + +## U-20260923-87 · 2026-09-23 · curl and HAR import: malformed URLs reported, -I, --oauth2-bearer, fragments, repeated -F · #fix #tools + +- **What**: the curl importer (`utils/curl_import/`) and the HAR importer produced the wrong request, or none, for common inputs. Found in the review sweep of `tools_gui/` and `diagram_editor/`. + - `curl 'http://[::1/api'` (or a port that is not a number) parsed, then `urlparse` in the code generator raised `ValueError` out of the tab. The tab kept the previous command's code, so Copy and Save then used the wrong script. A HAR entry with such a URL raised the same way while being listed: the list and "Generate all" kept the previous file's requests. + - `-I` / `--head` was skipped as a display flag, so the code sent GET. + - `--oauth2-bearer TOKEN` was consumed and dropped, losing the Authorization header. + - `https://x/a?b=1#frag` kept the fragment in the last query value (`b="1#frag"`). curl never sends a fragment. + - `-F f=@a.txt -F f=@b.txt` kept only the last file (a dict per field name), and so did a recorded multipart upload. +- **Fix**: + - `url_is_well_formed()` (`curl_parser.py`) is used by `parse_curl` (a malformed URL raises `CurlParseException` with the new `malformed_url_error`) and by `parse_har` (the entry is skipped and logged without its URL, which may carry a token). + - `-I` / `--head` sets `head_only`, and the method becomes HEAD unless `-X` names another. + - `--oauth2-bearer` is kept in `bearer_token` and applied at the end as `Authorization: Bearer …`, unless `-H` gave an Authorization header. + - The fragment is dropped before the query is split. + - `form_parts()` keeps every value of a repeated field as a list (`add_repeated_value`). `file_uploads()` lists the `(field, file)` pairs. The requests code and the APITestka call write `files=[(…), …]` when a field repeats, and a dict as before otherwise; `requests` sends both forms as multipart. +- **Tests**: + - `test_curl_import.py`: malformed URLs are a parse error; an IPv6 URL with a port is fine. + - `test_har_import.py`: an entry with a malformed URL is skipped. + - new `test_curl_request_fidelity.py`, 14 tests: HEAD (three spellings, and an explicit method wins), the bearer header (and an explicit header wins in either order), fragments, and repeated `-F` (the parts, the requests code, the APITestka call, a single field unchanged, a recorded multipart upload). +- **Result / numbers**: unit tests 1499 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py`, `request_body.py`, `request_codegen.py`, `script_templates.py` + - `pybreeze/utils/har_import/har_parser.py` + - `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/test_curl_import.py`, `test_har_import.py`, `test_curl_request_fidelity.py` (new) + - `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-88 · 2026-09-23 · Tools work on the text as entered, not Qt's display copy · #fix #tools + +- **What**: every tool read its input with `toPlainText()`, Qt's display copy of the text. It turns each non-breaking space (U+00A0) into a plain space and a line separator (U+2028) into a newline. Hash showed the SHA-256 of `"a b"` for `"a\u00a0b"`. Diff called `"price:\u00a0100"` and `"price: 100"` identical. A regex using `\xa0` never matched pasted text. JSON Format rewrote the contents of strings. Found in the review sweep of `tools_gui/` and `diagram_editor/`. +- **Fix**: new `tools_gui/exact_text.py`. `exact_text(edit)` returns `document().toRawText()`, with Qt's block separator (U+2029) turned back into `\n`. Every tool input uses it: curl import, Diff, Hash, Header Analyzer, JSON Format, JWT, Query/JSON, Regex, Response Inspector and URL Builder. +- **Tests**: new `test_exact_text.py`: + - both editor types give back every character; + - Hash digests the text as entered; + - Diff sees a non-breaking space. +- **Result / numbers**: unit tests 1503 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/tools_gui/exact_text.py` (new) + - `pybreeze/pybreeze_ui/tools_gui/{curl_import,diff,hash,header_analyzer,json_format,jwt_decoder,query_json,regex,response_inspector,url_builder}_gui.py` + - `test/test_utils/test_exact_text.py` (new) + - `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-89 · 2026-09-23 · Diagram undo keeps text typed on the canvas and images it cannot reload · #done #diagram + +- **What**: `progress.md` #50 and #51, two ways the diagram editor's undo lost work. Found in the review sweep of `diagram_editor/`. + - Text typed on the canvas (double-click on a node, connection or image label, `_EditableLabel`) took no undo snapshot. Undo restores whole-scene snapshots, so after "move the node, type Hello", Ctrl+Z went back to before the move and Ctrl+Y to just after it; "Hello" was in neither snapshot and could not be recovered. + - Every undo rebuilds each image from its `source`, but `add_image` never put its pixmap in `_pixmap_cache`. An image picked through "All Files" with an extension the editor does not reload (`.tiff`) went blank on the first undo, and an image added from a URL was downloaded again (a failed download left it blank, logged at debug level only). +- **Fix**: + - `_EditableLabel` records the scene when a double-click starts editing. When the focus leaves, `DiagramScene.record_change("Edit Text", before)` pushes one undo step if anything changed. `end_undo` uses the same helper. The command skips its first redo, so nothing is rebuilt while the label is still in its own event handler. + - `add_image` caches the pixmap under its source. +- **Tests**: new `test_diagram_undo_fidelity.py`: + - typing is one undo step; + - move, type, undo, redo gives the typed text back; + - opening the editor without typing records nothing; + - undo keeps the picture of a `.tiff` and of a URL image, with no download; + - a mouse move is still one step. +- **Result / numbers**: unit tests 1508 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `diagram_scene.py` + - `test/test_utils/test_diagram_undo_fidelity.py` (new) + - `architecture_explore.md` §7, §18 + - `progress.md` (#50 and #51 removed) +- **Open items**: none. + +## U-20260923-90 · 2026-09-23 · SSRF check refuses a URL whose host urlparse and urllib3 read differently · #fix #security #network + +- **What**: `validate_url` read the host with `urlparse`; `requests` connects to the host `urllib3` reads. The two disagree over a backslash: `http://127.0.0.1\@example.com/` is `example.com` to `urlparse` (public, passed) and `127.0.0.1` to `urllib3`, which `requests` then connected to. Every caller (AI review client, diagram image download, prthinker, the request tools) was open to it; the reported path reached a loopback listener and read its reply. Found in the review sweep of `extend/process_executor/` and `utils/network/`. +- **Fix**: new `_check_one_reading(url)` in `utils/network/url_validation.py`, run first by `validate_url`: + - a backslash, whitespace or a control character (below 0x20, or 0x7F) is refused; + - otherwise the host `urllib3.util.parse_url` reads is compared with the one `urlparse` reads (brackets stripped, lower-cased, IDNA-encoded) and a mismatch is refused; + - the messages are generic ("URL cannot be parsed.", "URL names its host ambiguously."): the parsers' own messages quote the whole URL, which may carry a token. +- **Tests**: new `test_url_parser_differential.py` (13): six ambiguous URLs refused (backslash before `@`, with a port, the metadata address, newline, tab, NUL), five ordinary URLs still pass (query and fragment, userinfo, upper case, an IDN, a port), the parser error does not quote the URL, and end to end a loopback listener behind `127.0.0.1:port\@example.com` is never reached. +- **Result / numbers**: unit tests 1521 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/network/url_validation.py` + - `test/test_utils/test_url_parser_differential.py` (new) + - `architecture_explore.md` §12, §18 + - `progress.md` (#52 added) +- **Open items**: #52 (the address checked is not the address connected to: DNS rebinding). + +## U-20260923-91 · 2026-09-23 · A run that cannot start says so in its window, and a Python run no longer reads the IDE's stdin · #fix #executor + +- **What**: three gaps in the two executors, found in the review sweep of `extend/process_executor/`. + - `TaskProcessManager._spawn_and_pump` did not catch `OSError` from `Popen`. An interpreter that had been removed, or a command line over Windows' limit (a large script run as `--execute_str`), raised out of the menu callback and left a run window nobody ever saw. + - `FileRunnerProcess._start_process` caught only `FileNotFoundError`. A compiler path that is a folder or not executable, or a compiled binary locked or blocked by antivirus, raised out of the menu, or out of the timer slot after a compile, and the window said nothing. The compiled binary was then left behind. + - The Python child inherited the IDE's stdin: a script calling `input()` blocked on the IDE's console and consumed what was typed there. The plugin runner already gave its children `DEVNULL`. +- **Fix**: + - `_spawn_and_pump` passes `stdin=subprocess.DEVNULL` and catches `OSError`: it logs, writes `[Error] could not start: ` on its own line in the window and shows it. + - `_start_process` also catches `OSError` and writes `[Error] Could not start : `. Both failure paths remove the compiled binary through the new `_remove_binary()`, which `_finish` now uses too. +- **Tests**: `test_run_output.py` +3: a missing interpreter is reported in the window, a script calling `input()` gets end of file, and a compiler that is a folder is reported. +- **Result / numbers**: unit tests 1524 passed, 14 skipped. Two child-IDE tests (`test_closing_the_ide`, `test_file_tree_rename`) timed out once while the machine was loaded and passed on their own. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_run_output.py` + - `architecture_explore.md` §4.1, §4.4, §18 +- **Open items**: none. + +## U-20260923-92 · 2026-09-23 · A run whose started process keeps its output open no longer freezes the IDE when it ends · #fix #executor + +- **What**: when the child exited, both executors joined each reader thread on the UI thread for up to two seconds (`TaskProcessManager.exit_program`, `FileRunnerProcess._finish`). A pipe reaches end of file only when the last process holding it exits, so a run that starts a process without redirecting its output (a server, a browser driver, a `subprocess.Popen` left running) froze the IDE for four seconds at its end, measured at 4.0 s in the new test, and what that process wrote later was lost without a word. Found in the review sweep of `extend/process_executor/`. +- **Fix**: + - New in `queue_pump.py`: `ReaderGrace` (`READER_GRACE_SECONDS = 2.0`, counted from the first tick after the exit), `any_alive()` and `OUTPUT_STILL_HELD_NOTE`. + - After the exit the pump keeps draining tick by tick while a reader is alive and the grace is not over, then ends the run. Nothing waits on the UI thread; output that comes within the grace is shown before the exit line. + - A reader still alive at the end means a process the run started holds the output: the window says what it writes from then on is not shown. +- **Tests**: `test_run_output.py` +3: a Python run and a plugin run whose grandchild holds the pipes end with no event pass over one second (both failed on the old code at 4.0 s) and show the note; output a grandchild writes within the grace comes before the exit line, with no note. +- **Result / numbers**: executor tests 78 passed; unit tests 1527 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/queue_pump.py`, `python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_run_output.py` + - `architecture_explore.md` §4.1, §4.5, §18 +- **Open items**: none. + +## U-20260923-93 · 2026-09-23 · Run and send: an earlier run's report is not mailed, and the run window says whether the mail went · #fix #executor #mail + +- **What**: the "and send" runs' done-hook (`send_after_test`) always mailed `default_name.html` from the IDE's working directory, found in the review sweep of `extend/process_executor/`: + - a run that failed before writing its report mailed the one an earlier run had left there, as if it were this run's; + - whether the mail went out, and why not (no `je_mail_thunder`, no report, no mail user, a failed login, an unreachable server), was only logged; the run window said nothing. +- **Fix**: + - `send_report(path, *, not_before=None)` returns None once the report is sent, else a short reason for the user (no path, address or server reply; those stay in the log). A report last written more than two seconds before `not_before` is not sent ("the run wrote no new default_name.html; the one there is from an earlier run"), and neither is a folder in its place. + - `send_after_test(..., not_before=, on_done=)` passes that answer to `on_done` on the mail thread. + - `build_task_process` makes the hook per run with the new `report_mail_hook(code_window)`: it takes the report's absolute path in the directory the child starts in and the start time. A parentless `_MailNotice` QObject carries the answer to the run window through a queued signal (checked to arrive on the main thread), which writes `[Mail] The test report was sent` or `[Mail] The test report was not sent: `. An answer after the window is gone is dropped. +- **Tests**: + - `test_mail_report.py` +9: an hour-old report is not sent, a fresh one is, a folder is not; the answer for sent, no user, failed login, failed send (the error kind only, not its text), no `je_mail_thunder`; `on_done` gets the answer. + - `test_build_task_process.py` +3: the window says it was sent (with the absolute report path and the start time asked for), says why not, and an answer after the window is deleted raises nothing on the mail thread. +- **Result / numbers**: unit tests 1539 passed, 14 skipped. `test_file_tree_rename` (a child IDE) timed out once while the machine was loaded and passed on its own. `ruff check` clean. +- **Files**: + - `pybreeze/extend/mail_thunder_extend/mail_thunder_setting.py` + - `pybreeze/extend/process_executor/process_executor_utils.py` + - `test/test_utils/test_mail_report.py`, `test_build_task_process.py` + - `architecture.md` (main flow), `architecture_explore.md` §4.2, §16, §18 +- **Open items**: none. + +## U-20260923-94 · 2026-09-23 · Requests to user URLs connect only to the address checked as they connect (DNS rebinding) · #done #security #network + +- **What**: `progress.md` #52. `validate_url` resolved the host and checked every address, then the request resolved it again to connect. A name answering a public address the first time and a private one the second (DNS rebinding) passed and was connected to. Reproduced before the fix: a name answering `93.184.215.14` then `127.0.0.1` passed `validate_url`, and both a plain `requests.get` and the diagram editor's `urllib` opener read `SECRET` from a loopback listener. Callers: the AI code review client, CoT code review, Skills send and diagram image downloads. +- **Fix**: + - `url_validation.public_address(hostname)`: resolves the name, refuses it when any address is blocked, and returns the address to connect to. `validate_url` uses it. + - New `utils/network/public_http.py`. Its connections call `public_address` again as they connect and connect to the address it returns; the name stays the connection's host, so TLS checks the certificate against it (SNI) and the `Host` header carries it. + - For `requests`: `public_session()` mounts `PublicAddressAdapter`, whose pools use connections that swap urllib3's `_dns_host` for the checked address only while the socket opens. A refusal is a `NewConnectionError`, so callers get the `requests.ConnectionError` they already catch. + - For `urllib`: `PublicHTTPHandler` / `PublicHTTPSHandler` give `http.client` connections a `_create_connection` that connects to the checked address. A refusal is an `AddressNotPublicError` (`OSError`), so `urlopen` raises the `URLError` the canvas already catches. + - Through a proxy nothing is pinned: the proxy resolves, and it may well be on the local network. + - Callers: `skills_send_gui`, `code_review_thread` and `ai_code_review_gui` send through `public_session()` (the review client through `session.request` with the chosen method); `diagram_net_utils._OPENER` uses the two handlers. +- **Tests**: + - New `test_public_http.py` (9), with a fake DNS that answers the port asked for. An earlier fake answered port 0, which made the old code fail as well and would have proved nothing. For both `requests` and `urllib`: a rebinding name never reaches the loopback listener; a checked name is connected to under its own name (`Host` header, and the TLS ClientHello's server name); a proxy on the local network is still used. A rebinding image download fails with an `OSError`. + - New `test_http_goes_through_public_connections.py` (2): no module outside `public_http.py` calls `requests.get/post/.../Session` or `urlopen` directly, and the scan tells a call from generated code in a string. + - The Skills, AI review client and CoT tests hand in a stand-in session instead of patching `requests`; the review client's test now also asserts the method sent. +- **Result / numbers**: unit tests 1550 passed, 14 skipped. `ruff check` clean. +- **Files**: + - `pybreeze/utils/network/url_validation.py`, `public_http.py` (new) + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/code_review_thread.py`, `connect_gui/url/ai_code_review_gui.py`, `diagram_editor/diagram_net_utils.py` + - `test/test_utils/test_public_http.py`, `test_http_goes_through_public_connections.py` (new), `test_skills_request.py`, `test_ai_code_review_client.py`, `test_cot_session_reuse.py` + - `CLAUDE.md` (tree, Security › Network), `architecture.md`, `architecture_explore.md` §7, §12, §18 + - `progress.md` (#52 removed) +- **Open items**: none. + +## U-20260923-95 · 2026-09-23 · Create Project writes into the folder open in the IDE and asks before it replaces a template · #fix #menu + +- **What**: the Project › Create entry of six automation menus (APITestka, AutoControl, FileAutomation, LoadDensity, MailThunder, WebRunner) called `package.create_project_dir()` with no arguments and caught only `ImportError`. Found in the review sweep of `pybreeze_ui/menu/`. + - The files went to the process's working directory, not the folder open in the IDE, and nothing said where. + - Every package rewrites its `keyword/*.json` and `executor/*.py` whether they exist or not: a second click wiped the user's edited templates without a word (reproduced with `je_web_runner`: an edited `keyword1.json` was back to the template). + - A write that failed (a read-only folder) raised out of the menu slot, and success was silent too. + - TestPioneer's template entry had already been fixed for the same three things. +- **Fix**: `safe_create_project(ui, import_name)` in `automation_menu_factory.py` creates the project under `ui.working_dir` (the process's working directory when no folder is open). When the package's folder already exists (`_project_folder_name`: the `parent_name` default of its `create_project_dir`) it asks first, default No. A failure or a missing package is shown in a warning, and success names the folder. New keys `create_project_exists`, `create_project_failed`, `create_project_not_installed`, `create_project_created` in both languages. The six menu builders pass their main window. +- **Tests**: new `test_create_project.py` (6): into the open folder and not the process's directory; an edited template kept on No and replaced on Yes; a failed write reported, not raised; a missing package reported; the working directory when no folder is open. `test_automation_menu_factory.py`'s check that every entry names an installed package reads the new call form. +- **Result / numbers**: menu, main window and startup tests 217 passed; language parity passed. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/automation_menu/automation_menu_factory.py` and the six `build_*_menu.py` that use it + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_create_project.py` (new), `test_automation_menu_factory.py` + - `architecture_explore.md` §5.1, §18 +- **Open items**: none. + +## U-20260923-96 · 2026-09-23 · A failed AI request is described in its panel without the URL and any token in it · #fix #security #ai + +- **What**: the Skills panel (`skills_send_gui`), each CoT review step (`code_review_thread._ask`, also kept in the panel's answers) and the AI code review client (`ai_code_review_gui.ReviewRequestThread`) showed a failed request as `str(error)`. A `requests` error quotes the URL, path and query included (`... Max retries exceeded with url: /v1/review?api_key=...`), and CLAUDE.md treats API URLs as credentials. The log lines already left the URL out; the panels displayed it. Found in the review sweep of `extend_ai_gui/` (the review client was found alongside it). +- **Fix**: new `describe_request_error(error)` in `utils/network/http_client.py` says what went wrong: timed out, the secure connection could not be made, could not connect, too many redirects, the URL is not valid (each with the error's type name), else the type name alone. The size cap's and the SSRF check's own messages name no path or query and are kept. The three panels use it. +- **Tests**: + - New `test_request_error_text.py` (11): each kind of failure is described with neither the token nor the host; the size cap and SSRF messages are kept; the Skills panel, the review client and a CoT step show no token for an error quoting the URL. + - Two tests that asserted the raw error text in the panel now assert the description: `test_skills_request.py` ("could not connect to the server") and `test_cot_session_reuse.py` (the type name). +- **Result / numbers**: AI panel tests 63 passed; 1581 tests collected in 105 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/network/http_client.py` + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py`, `extend_ai_gui/code_review/code_review_thread.py`, `connect_gui/url/ai_code_review_gui.py` + - `test/test_utils/test_request_error_text.py` (new), `test_skills_request.py`, `test_cot_session_reuse.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-97 · 2026-09-23 · Skills panel: switching the template keeps an edited prompt unless the user agrees, and a prompt without its code is not sent · #fix #ai + +- **What**: two ways the Skills panel (`skills_send_gui.SkillsSendGUI`) lost or wasted what the user did. Found in the review sweep of `extend_ai_gui/`. + - The edit area is the only input: the user pastes code into the template in place of `{code_diff}`. Changing the template selector replaced the edit area at once, pasted code and all; the prompt editor already asks before this. + - Pressing Send on a template as loaded posted the literal `{code_diff}`, and the endpoint was asked to review no code. +- **Fix**: + - `load_selected_prompt` asks before replacing an edited prompt (`skills_switch_over_edits`, default No). On No the selector goes back to the template in the edit area, with its signals blocked. A freshly loaded template is marked unedited. + - `send_prompt` refuses a prompt still holding `CODE_PLACEHOLDER` (`{code_diff}`) and says where the code goes (`skills_code_missing`). + - New keys in both languages. +- **Tests**: new `test_skills_panel.py` (5): an untouched prompt switches without a question; an edited one is kept on No (with the selector back on its template) and replaced on Yes, after which the next switch does not ask; a template with `{code_diff}` in it starts no request; one with the code in place does. +- **Result / numbers**: AI panel, prompt store and language parity tests 65 passed; 1586 tests in 106 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/skills/skills_send_gui.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_skills_panel.py` (new) + - `architecture_explore.md` §8, §18 +- **Open items**: none. + +## U-20260923-98 · 2026-09-23 · Reading a prompt creates nothing and cannot stop a review on a folder it may not look into · #fix #ai + +- **What**: `prompt_store.prompt_dir()` went through `pybreeze_data_dir()`, which creates `~/.pybreeze`, although its docstring says reading a prompt creates nothing. The CoT review reads each step's prompt on its worker thread, and two things raised out of `SenderThread.run` there, ending the review at its first step with nothing in the panel: a file where `~/.pybreeze` should be (`FileExistsError`), and `Path.is_file()` on a prompt folder the user may not look into (`PermissionError` on Windows). Found in the review sweep of `extend_ai_gui/`. +- **Fix**: `prompt_dir()` uses `pybreeze_data_path()`, which creates nothing; saving a prompt already makes the folder it writes into. `load_prompt` falls back to the built-in prompt, with a log line, when looking the file up raises `OSError`. +- **Tests**: `test_prompt_store.py` +3: looking at a prompt leaves no `~/.pybreeze`; a file in its place gives the built-in; a folder that refuses the lookup gives the built-in. Its fixture and `test_skills_panel.py`'s now point `pybreeze_data_path` at a temporary folder. +- **Result / numbers**: prompt store, Skills, CoT and AI panel lifecycle tests 87 passed. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/prompt_store.py` + - `test/test_utils/test_prompt_store.py`, `test_skills_panel.py` + - `architecture_explore.md` §8, §18 +- **Open items**: none. + +## U-20260923-99 · 2026-09-23 · Plugin runs match suffixes registered in any case or without the dot, and the Plugins menu has one run entry per plugin · #fix #plugin + +- **What**: two faults in the plugin run entries, found in the review sweep of `pybreeze_ui/menu/`. + - `run_current_file_with` lower-cased the file's suffix but compared it with the suffixes as the plugin registered them, and JEditor keeps those as given. A plugin registering `(".R",)` or `("r",)` always got the suffix mismatch dialog, after the file had been saved. + - The Plugins menu gave a plugin with several suffixes one "Run with X (.suffix)" entry per suffix, and every one ran the same config: the file's own suffix decides what runs. +- **Fix**: + - New `run_config_suffixes(run_config)` in `build_run_with_menu.py`: lower case, one leading dot, blanks and non-strings dropped, a bare string taken as one suffix, duplicates removed. Used by the match, the Run With labels and the Plugins menu. + - The Plugins menu has one run entry per plugin, listing its suffixes when there are several. +- **Tests**: `test_plugin_menu.py` +7: six normalisation cases, and a `.r` file runs under a plugin registering `.R`. The test that expected one entry per suffix now expects one entry listing them. +- **Result / numbers**: plugin menu and JEditor contract tests 67 passed; 1596 tests in 106 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/menu/plugin_menu/build_run_with_menu.py`, `build_plugin_menu.py` + - `test/test_utils/test_plugin_menu.py` + - `architecture_explore.md` §5, §18 +- **Open items**: none. + +## U-20260923-100 · 2026-09-23 · URL Builder and Query JSON: deeply nested JSON reported, null parts empty, ports checked, an empty port accepted · #fix #tools + +- **What**: four faults in `utils/url_tools/url_convert.py` and `utils/query_tools/query_convert.py`, all reproduced. Found in the review sweep of `utils/`. + - `json_to_url` and `json_to_query` caught only `ValueError` from `json.loads`. JSON nested deeper than the parser goes (`"[" * 100000`) raised `RecursionError`, and the URL Builder and Query JSON tabs catch only their own exceptions, so it escaped the slot. + - `build_url` wrote parts with `str()`: `{"path": null, "fragment": null}` built `http://h/None#None`, and a query value `null` built `a=None` (the query tool writes `a=`). A query value that was an object was flattened to its keys (`b=x`), and a port `"abc"` built `http://h:abc`. + - `url_to_json("http://example.com:/p")` refused the URL as having a port out of range. RFC 3986 allows the colon with an empty port. +- **Fix**: + - Both catch `(ValueError, RecursionError)`. + - `build_url`: a null or missing part is empty (`_part`). Query values go through the query tool's `coerce_scalar` (was `_coerce_scalar`): null is empty, booleans `true`/`false`, an object or a nested list refused as a `UrlConvertException`. The port (`_port_text`) must be a whole number from 0 to 65535, given as a number or as digits. + - `_has_port_text` counts a port only when text follows the colon. +- **Not changed**: a URL taken apart and rebuilt can still change its query's spelling (a malformed escape such as `%zz` comes back as `%25zz`, invalid UTF-8 as U+FFFD, `/` and `;` percent-encoded, a bare `flag` as `flag=`). The tool shows decoded values for editing, so keeping the raw spelling would take a different design. +- **Tests**: `test_url_convert.py` +14 (the empty port; null parts; null and boolean query values; an object value refused; five bad ports refused; four good or absent ports build; nested JSON reported) and `test_query_convert.py` +1 (nested JSON reported). +- **Result / numbers**: URL and query tests 64 passed; 1611 tests in 106 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/query_tools/query_convert.py` + - `test/test_utils/test_url_convert.py`, `test_query_convert.py` + - `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-101 · 2026-09-23 · prthinker settings: a failed save keeps the stored keys, the file is its owner's only, reading creates nothing, and a failure is shown · #fix #security #prthinker + +- **What**: four faults in the prthinker settings (`extend/prthinker_extend/prthinker_setting.py`, `pybreeze_ui/dialog/prthinker_setting_dialog.py`), found in the review sweep of `editor_main/`, `dialog/` and prthinker. + - `save_setting` wrote with `Path.write_text`, which empties the file first. A full disk or a crash part-way left `prthinker_setting.json` empty or cut short; the next read fell back to the defaults, every API key and token was gone, and the next Save stored the defaults. + - The file, holding the remote, OpenAI and Anthropic keys and the forge token, was created with the default umask: readable by every local user on a shared POSIX machine (`0644`), as was `~/.pybreeze` (`0755`). + - `setting_path()` went through `pybreeze_data_dir()`, which creates `~/.pybreeze`, outside any `try`. With a file in its place (reproduced) or a read-only home, "Review current file", "Review PR", "Settings" and "Install prthinker" all raised out of their slots with no message. The same bug class as U-20260923-98 in the prompt store. + - When Save failed the dialog stayed open with no reason given; it was only logged. +- **Fix**: + - New `utils/file_process/replace_file.replace_text(path, text, *, private=False)`: writes `.saving` beside the file (created `0600` when `private`, and removing a leftover from an earlier failure first), then `os.replace`s it over the file. On failure the file is unchanged, the partial one is removed and the `OSError` raised. + - `save_setting` makes the folder (`DATA_DIR_MODE`, `0700`) and writes through `replace_text(..., private=True)`. + - `setting_path()` uses `pybreeze_data_path()`, and `load_setting` treats an `OSError` from the lookup as nothing stored. + - `app_dirs.pybreeze_data_dir()` creates `~/.pybreeze` as `0700`. An existing folder is left as it is, and Windows keeps the profile's own access rules. + - The dialog warns when Save fails (`prthinker_setting_save_failed`, both languages), naming the file and saying the stored settings are kept. +- **Tests**: + - `test_prthinker_setting.py` +3: a save failing at the replace keeps the stored key and leaves no partial file; the file is opened with mode `0600`; a file where the folder should be reads as defaults and fails to save without raising. The failure test now fails at `replace_text` instead of patching `Path.write_text`. + - New `test_replace_file.py` (4, one POSIX-only): written; a failure keeps the old file with no partial one; a leftover partial file is replaced; a private file is `0600`. + - `test_app_dirs.py` +1 (made `0700`), and the dialog's failed-save test now also checks the warning. + - The fixtures point `pybreeze_data_path` at a temporary folder. +- **Result / numbers**: prthinker, app dirs, install menu and language parity tests 96 passed; 1619 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/file_process/replace_file.py` (new), `pybreeze/utils/app_dirs.py` + - `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_replace_file.py` (new), `test_prthinker_setting.py`, `test_prthinker_setting_dialog.py`, `test_app_dirs.py` + - `CLAUDE.md` (File I/O, Secrets), `architecture_explore.md` §12, §14, §17, §18 +- **Open items**: the prompt editors and the diagram editor each keep their own copy of the same write-beside-and-replace steps; moving them onto `replace_text` is a refactor for its own stage. + +## U-20260923-102 · 2026-09-23 · Prompt and diagram saves use the shared replace_text (refactor) · #refactor #tools + +- **What**: the open item of U-20260923-101. `prompt_file_io.save_prompt_text` and `DiagramEditorWidget._write_json` each kept their own copy of "write `.saving`, `os.replace` it over the file, remove it on failure". No behaviour change. +- **Change**: both call `utils/file_process/replace_file.replace_text`; their own `os` imports and cleanup lines are gone. The error handling around them (a warning dialog, the log line, a diagram that cannot be serialised) is unchanged. +- **Tests**: the two tests that make the replace fail (`test_prompt_store.py`, `test_diagram_editor_widget.py`) now inject the failure where `os.replace` is called, in `replace_file`; their assertions are unchanged (the last good file kept, nothing left beside it). Prompt, diagram and replace tests 126 passed, 1 skipped (POSIX-only). +- **Result / numbers**: `ruff check` clean. `diagram_editor/` 3,760 lines. +- **Files**: + - `pybreeze/pybreeze_ui/extend_ai_gui/prompt_edit_gui/prompt_file_io.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py` + - `test/test_utils/test_prompt_store.py`, `test_diagram_editor_widget.py` + - `architecture_explore.md` §7, §8 +- **Open items**: none. + +## U-20260923-103 · 2026-09-23 · JSON Format and Minify keep numbers and characters as written, and report a repeated key · #fix #tools + +- **What**: the JSON Format tool (`utils/json_format/json_process.py`, `reformat_json` and `minify_json`) changed what the JSON said. Found in the review sweep of `utils/`; each case reproduced. + - Every number went through `float`/`int`. `1e400` came back as `Infinity`, which is not JSON. `12345678901234567890123.0` came back as `1.2345678901234568e+22` and `0.10000000000000000555` as `0.1`. An integer past Python's 4300-digit limit was refused as invalid. + - A key given twice in one object kept only its last value, without a word. + - Every non-ASCII character came back as a `\u` escape (`"中文"` as `"\u4e2d\u6587"`). + - `NaN` and `Infinity`, which are not JSON, were accepted. +- **Fix**: + - A number is parsed to a placeholder carrying a random marker (`_Numbers`), and after serialising one regex pass puts each number's own text back (200,000 numbers in 0.24 s). A string in the input cannot be mistaken for a placeholder. + - `object_pairs_hook` refuses a repeated key with `json_duplicate_key_error` naming it. `parse_constant` refuses `NaN` and `Infinity`. + - `ensure_ascii=False`. Format still indents by four and sorts keys; Minify keeps key order. + - `reformat_json`'s error adds the reason after `cant_reformat_json_error`. +- **Tests**: `test_json_process.py`: + - +14: seven number spellings kept by both; characters kept; a string shaped like a placeholder stays a string; a repeated key reported by both, while the same key in two objects is fine; `NaN`, `Infinity`, `-Infinity` refused. + - The 5000-digit integer test now expects the number back digit for digit instead of an error. +- **Result / numbers**: JSON and fuzz tests 53 passed; 1633 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/json_format/json_process.py`, `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/test_json_process.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-104 · 2026-09-23 · Header Analyzer and Response Inspector read quoted max-age, folded lines, header names in any case and HTTP/2 :status · #fix #tools + +- **What**: header parsing in `utils/header_tools/header_analyzer.py` and `utils/response_inspector/response_analyzer.py` departed from HTTP. Found in the review sweep of `utils/`; each case reproduced. + - `Strict-Transport-Security: max-age="31536000"` (RFC 6797 allows the quotes) read as max-age 0 and was reported as `hsts_weak_max_age`. + - A folded continuation line (obs-fold, RFC 9112 §5.2) was dropped by the analyzer, so a CSP directive written on it (`script-src 'unsafe-inline'`) went unchecked. In the inspector it ended the headers: the value was cut, and the rest, body included, stopped reading as JSON. + - The inspector grouped repeated headers by exact spelling: `Set-Cookie` and `set-cookie` were two entries instead of one list. + - An HTTP/2 `:status: 200` line, as browsers' developer tools copy it, ended the headers, and everything after it was read as the body. +- **Fix**: + - `_MAX_AGE_RE` accepts an optional quote around the digits. + - A line starting with a space or a tab (`FOLDED_LINE_START`) joins the value above with one space, in both parsers. + - The inspector keeps a header under the spelling first seen (`_same_name`). + - Pseudo-headers are skipped, and `:status` gives the status code when there is no status line. +- **Tests**: `test_header_analyzer.py` +3 (quoted max-age; a folded CSP line checked and joined; a tab folds) and `test_response_analyzer.py` +3 (a folded line with the body still JSON; names in two cases in one list; `:status` giving the status with the headers and body intact). +- **Result / numbers**: header and response tests 68 passed; 1639 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/header_tools/header_analyzer.py`, `pybreeze/utils/response_inspector/response_analyzer.py` + - `test/test_utils/test_header_analyzer.py`, `test_response_analyzer.py` + - `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-105 · 2026-09-23 · A file-tree rename moves a docked editor and the tab's watch, highlighter, git baseline and language server with the file · #fix #editor + +- **What**: two things a rename in the file tree (`editor_main/file_tree_context_menu.py`, `_action_rename`) left on the old name. Found in the review sweep of `editor_main/`. + - A file open in JEditor's Dock Editor (`FullEditorWidget`). It has no auto-save: it writes its buffer when it closes, and only if its file still exists. After a rename it wrote nothing, and every edit made in it was lost (reproduced). + - The tab's watch for changes made outside the IDE (`EditorWidget._file_watcher`). A change to the renamed file raised no question, and the tab's auto-save wrote over it. The highlighter, git baseline and language server also stayed those of the old name, so renaming `notes.txt` to `notes.py` kept plain-text highlighting. A reproducing test showed the watch still on `a.txt` after renaming it to `b.py`. +- **Fix**: + - `_dock_editors_under` finds the docked editors on the file or under the folder (`findChildren(FullEditorWidget)`), and a successful rename re-points their `current_file`. + - `_stop_auto_save` also stops the watch (`_unwatch`), before the file moves: once it is gone, Windows keeps the old name in the watcher. + - `_start_auto_save` watches the new path, clears a leftover `_ignore_next_change`, and reloads the highlighter, git baseline and language server, as JEditor's `open_an_file` does. +- **Tests**: + - `test_file_tree_rename.py` +1, a started IDE: the tab watches only the new name and is not ignoring changes; a docked editor on the file follows it and saves its edits there on close; one on another file is left alone. + - `test_file_tree_context_menu.py`: the tab-follows test also checks the watch and the three reloads. Its stand-in editor gains a watcher and a code edit recording reloads, and the stand-in window is a `QWidget`. + - A `FullEditorWidget` built in the pytest process makes the interpreter exit with code 127 at shutdown (its editor's workers; the IDE ends with `os._exit`), so docked editors are tested only in a started IDE. + - `test_jeditor_contract.py` +3: `FullEditorWidget` listed, and the private members the rename relies on pinned. +- **Result / numbers**: file tree, rename and contract tests 78 passed; 1643 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/editor_main/file_tree_context_menu.py` + - `test/test_utils/test_file_tree_rename.py`, `test_file_tree_context_menu.py`, `test_jeditor_contract.py` + - `architecture.md` §6 (the JEditor internals table and prose), `architecture_explore.md` §3, §18 +- **Open items**: none. + +## U-20260923-106 · 2026-09-23 · prthinker settings refuse extra arguments with an open quote at Save instead of dropping them at run time · #fix #prthinker + +- **What**: an "extra arguments" value with a quote left open (`--rules "only these`) was saved as typed. At run time `extra_arguments` could not split it, logged that, and dropped the whole list, so the review ran without arguments the user thought it had. A limiting flag among them was lost with nothing on screen. Found in the review sweep of `dialog/` and prthinker. +- **Fix**: + - New `read_extra_arguments(text)` in `prthinker_setting.py` splits the field as the run does and raises `ValueError` when it cannot. `extra_arguments` uses it and keeps its run-time fallback for a hand-edited file. + - The dialog's Save checks the field first. If it cannot be split, it warns (`prthinker_setting_bad_extra_arguments`, both languages), stays open and writes nothing. +- **Tests**: `test_prthinker_setting_dialog.py` +2: an open quote is warned about, the window stays open and no file is written; a quoted argument is saved as typed. +- **Result / numbers**: prthinker and language parity tests 89 passed; 1645 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/extend/prthinker_extend/prthinker_setting.py`, `pybreeze/pybreeze_ui/dialog/prthinker_setting_dialog.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_prthinker_setting_dialog.py` + - `architecture_explore.md` §14, §18 +- **Open items**: none. + +## U-20260923-107 · 2026-09-23 · Tool output is copied and saved as generated, and Regex and HAR report patterns and files they cannot take · #fix #tools + +- **What**: three faults in the tool tabs, found in the review sweep of `tools_gui/`; each reproduced. + - `OutputActions` (Copy, Open in editor, Save to file on every tool) read the output with `toPlainText()`, which turns U+00A0 into a space and U+2028 into a newline. U-20260923-88 fixed the input side only. A cURL body holding either one was generated right, but copied and saved changed: the saved `request.py` no longer parsed ("unterminated string literal"). + - `regex_tester.compile_pattern` caught only `re.error`. `a{4294967296}` (`OverflowError`) and groups nested 5000 deep (`RecursionError`) escaped the worker's `run`: the button came back, but the output stayed on "Running the pattern..." with no error shown. + - `har_parser` caught only `ValueError` from `json.loads`. A `.har` of deeply nested JSON raised `RecursionError` out of the HAR tab's Open and Load slots. +- **Fix**: `OutputActions` reads through `exact_text()`. `compile_pattern` also turns `OverflowError` and `RecursionError` into `RegexTesterException`, and the HAR loader turns `RecursionError` into `HarParseException`. +- **Tests**: + - `test_output_actions.py` +2: copy and save keep U+00A0 and U+2028, and the saved script parses. + - `test_regex_tester.py` +2: the huge repeat and the deep nesting are reported. + - `test_har_import.py` +1: deep nesting is reported. +- **Result / numbers**: output, regex, HAR and exact-text tests 126 passed; 1650 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/tools_gui/output_actions.py`, `pybreeze/utils/regex_tools/regex_tester.py`, `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/test_output_actions.py`, `test_regex_tester.py`, `test_har_import.py` + - `architecture_explore.md` §6, §18 +- **Open items**: the rest of the same sweep's findings, taken in the stages that follow. + +## U-20260923-108 · 2026-09-23 · cURL and HAR import send a form as multipart without the copied Content-Type, and the JSON action refuses what it cannot carry · #fix #tools + +- **What**: the generated code sent a form differently from curl. Found in the review sweep of `tools_gui/`; each case reproduced. + - curl sends every `-F` form as `multipart/form-data`. Text fields were generated as `data=`, so a text-only form (`curl -F name=x -F 'note=a b'`) went out URL-encoded (`name=x¬e=a+b`). This hit the requests, pytest and APITestka Python targets, and HAR multipart entries without uploads. + - A copied `Content-Type: multipart/form-data` header (the Swagger form without a boundary, or a HAR entry with the browser's boundary) was kept in `headers=`. `requests` does not replace a header it is given, so the body went out under a boundary it did not use, and the server could not read it. + - The APITestka JSON action target kept a form's text fields as `data`, and left a file upload or a body read with `-d @file` out without a word: the action sent a different request. +- **Fix**: + - `request_body.form_entries()` lists every field; the generators put all of them in `files=`, a text field as `(None, text)` and an upload as its opened file. The result is a dict, or pairs when a name repeats (`form_value_expr`, `form_has_repeats`, shared by `request_codegen` and `script_templates`). + - `request_body.sent_headers()` leaves a multipart `Content-Type` out of a form's headers. + - The JSON action writes text fields as `"files": {name: [null, text]}`, which `requests` reads as `(None, text)`. It raises `CurlParseException` (`action_cannot_read_files_error`: choose a Python target) for an upload or a `@file` body. + - The HAR tab now catches that and shows `har_import_generate_error`, where it raised out of its slot. +- **Tests**: + - `test_curl_request_fidelity.py` +4, each running the generated code against a stand-in `requests` and preparing what it would send: a text-only form goes out multipart; a copied multipart header does not hide the boundary while other headers stay; a JSON body keeps its type; a recorded HAR form leaves the browser's boundary out. Three of them fail on the old code. + - `test_script_templates.py` +2: the JSON action refuses an upload and a `@file` body. + - `test_har_import_gui.py` +1: the refusal is shown. + - Four tests that asserted the old `data=` form now assert the multipart one. +- **Result / numbers**: cURL, HAR and template tests 316 passed; 1657 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/request_body.py`, `request_codegen.py`, `script_templates.py`, `pybreeze/utils/exception/exception_tags.py` + - `pybreeze/pybreeze_ui/tools_gui/har_import_gui.py`, `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_curl_request_fidelity.py`, `test_script_templates.py`, `test_har_import_gui.py` + - `architecture_explore.md` §12, §18 +- **Open items**: the same sweep's `-d @file` / `--data-binary @file` reading, `-b file`, `-G` with `@file`, the Diff tab's UI-thread work and the re-encoded query string follow in later stages. + +## U-20260923-109 · 2026-09-23 · cURL import reads @file bodies as curl sends them and stops sending a -b cookie file's name as a cookie · #fix #tools + +- **What**: four `@file` and `-b` mistranslations, found in the review sweep of `tools_gui/`; each reproduced. + - A body read from a file (`-d @f`, `--data-binary @f`) was generated as `open(f, encoding="utf-8").read()`. A binary file (`--data-binary @photo.jpg`) made the script raise `UnicodeDecodeError`, text mode rewrote CRLF on Windows, and a `-d` file kept the carriage returns and newlines curl strips. + - `curl -b cookies.txt` names a cookie file curl reads; it was sent as the header `Cookie: cookies.txt`. + - `curl -G -d @query.txt` sent the file as a GET body, not as the query string. +- **Fix**: + - `CurlRequest.binary_data_files` marks the files given with `--data-binary`. `data_from_file_expr` reads every file as bytes, `--data-binary` exactly and `-d` without `\r` and `\n`, inline data `.encode()`d, joined by `b"&"`. + - `-b ` goes to `CurlRequest.cookie_files`. The Python targets write a comment saying the script does not read it (`cookie_file_notes`); the APITestka JSON action refuses it, as it refuses uploads. + - `-G` with a body file raises `CurlParseException` (`get_with_file_body_error`): the query is the file's content, which the script cannot know. +- **Tests**: + - `test_curl_request_fidelity.py` +5, running the generated code against a stand-in `requests`: a binary file is sent byte for byte; a `-d` file loses its line breaks, joined after the inline part; `-G` with a file is refused; the cookie file is a comment, not a header; the JSON action refuses it. + - Three tests asserting the old UTF-8 reading and the `Cookie` header now assert the new behaviour. +- **Result / numbers**: cURL, HAR, template and fuzz tests 341 passed; 1662 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py`, `request_codegen.py`, `script_templates.py`, `pybreeze/utils/exception/exception_tags.py` + - `test/test_utils/test_curl_request_fidelity.py`, `test_curl_import.py`, `test_script_templates.py` + - `architecture_explore.md` §12, §18 +- **Open items**: the Diff tab's UI-thread work and the re-encoded query string, from the same sweep. + +## U-20260923-110 · 2026-09-23 · The Diff tab matches the lines once and off the UI thread · #fix #tools #performance + +- **What**: `DiffGUI.compare` ran `diff_summary` and then `unified_diff` on the UI thread, and each built its own `SequenceMatcher` over every line. Measured here on two pretty-printed JSON arrays of 40,000 lines with a tenth of the values changed: 4.1 s for the counts plus 4.9 s for the diff, the IDE frozen throughout. The review sweep of `tools_gui/` measured 10.6 s at 50,000 lines. +- **Fix**: + - New `text_diff.compare_texts()` matches the lines once and returns a `Comparison` (the `DiffSummary` and the diff text). The unified diff is written from that matcher's grouped opcodes exactly as `difflib.unified_diff` writes it (`_unified_lines`, `_unified_range`); checked against `difflib` on 3,000 random cases. `unified_diff` and `diff_summary` stay, as wrappers. + - `DiffGUI` compares on a `DiffThread`: "Comparing…" (`diff_comparing`, both languages) until the result arrives, the button off meanwhile, a second Compare ignored while one runs, and a comparison still running on close left to `let_run_out`. +- **Tests**: + - `test_text_diff.py` +1: a Hypothesis property that `compare_texts` writes `difflib.unified_diff`'s diff. + - `test_diff_gui.py` +2: with 20,000-line texts, `compare()` returns in under half a second and the counts arrive after; a second Compare while one runs is ignored. + - The existing tab tests and `test_exact_text.py`'s diff test now wait for the result. +- **Result / numbers**: diff and exact-text tests 27 passed, text diff 17; 1665 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/diff_tools/text_diff.py`, `pybreeze/pybreeze_ui/tools_gui/diff_gui.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_text_diff.py`, `test_diff_gui.py`, `test_exact_text.py` + - `architecture_explore.md` §6, §16, §18 +- **Open items**: the query string re-encoded by the cURL and HAR import, from the same sweep. + +## U-20260923-111 · 2026-09-23 · cURL and HAR import send a query that would not survive re-encoding as it was written · #fix #tools + +- **What**: the URL's query was always decoded into `params` (`parse_qsl`) and encoded again on the way out (`urlencode`, or `requests`' own). `curl 'https://h/a?flag&q=%B0&r=/x&s=a,b'` went out as `?flag=&q=%EF%BF%BD&r=%2Fx&s=a%2Cb`: a bare key gained `=`, a byte that is not UTF-8 became U+FFFD, and `/`, `,` and `%20` were re-spelled. That breaks a signed URL (an S3 presigned link) and any server that reads the query byte for byte. It affected the generated code, the URL Builder hand-off, the LoadDensity target and the HAR list. Found in the review sweep of `tools_gui/`; reproduced. +- **Fix**: + - New `curl_parser.query_round_trips(query)`: whether decoding and encoding again gives the query back. + - Only such a query is split into `params` (cURL's `_split_url_query`, HAR's `_apply_query`). Any other stays in the URL as written, which `requests` sends unchanged; `-G` data and a HAR entry's recorded `queryString` names the URL did not carry still go to `params`, added after it. + - The trade-off: a query spelled that way is no longer an editable dict in the generated code. +- **Tests**: + - `test_curl_import.py` +5: queries that would change stay in the URL, and `full_url` is the original. + - `test_curl_request_fidelity.py` +4: the URL `requests` prepares from the generated code is the pasted one, including a presigned-style URL; `-G` data is appended after it. + - `test_har_import.py` +1 (a recorded URL kept as recorded). + - Three tests whose example queries used `%20` now use `+`, which round-trips. +- **Result / numbers**: cURL, HAR, template and fuzz tests 347 passed, fidelity 27; 1675 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/test_curl_import.py`, `test_har_import.py`, `test_curl_request_fidelity.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none; this closes the findings of the `tools_gui/` sweep. + +## U-20260923-112 · 2026-09-23 · URL Builder rebuilds a query it cannot decode losslessly exactly as it was · #fix #tools + +- **What**: the item left open in U-20260923-100. A URL taken apart and rebuilt in the URL Builder with nothing edited changed its query: `%zz` came back as `%25zz`, `%ff` as `%EF%BF%BD`, a bare `flag` as `flag=`, `/` and `;` percent-encoded, `%20` as `+`. +- **Fix**: + - `query_round_trips()` moves to `utils/query_tools/query_convert.py`, where the cURL and HAR import now take it from (it was in `curl_parser.py` since U-20260923-111). + - `url_convert.parse_url` gives the query as a dict only when it round-trips, and otherwise as its text. `build_url` already writes a text query as it is. +- **Tests**: `test_url_convert.py` +5: four such queries are text and rebuild unchanged; one that round-trips is still a dict. The test that decoded `%20` now decodes `+`, which round-trips. +- **Result / numbers**: URL, query, cURL, HAR and fuzz tests 366 passed; 1680 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/utils/query_tools/query_convert.py`, `pybreeze/utils/url_tools/url_convert.py`, `pybreeze/utils/curl_import/curl_parser.py`, `pybreeze/utils/har_import/har_parser.py` + - `test/test_utils/test_url_convert.py` + - `architecture_explore.md` §12, §18 +- **Open items**: none. + +## U-20260923-113 · 2026-09-23 · The run window shows output past its 10,000-line cap without freezing the IDE · #fix #executor #performance + +- **What**: `CodeWindow`'s output was a `QTextEdit` capped at `MAX_OUTPUT_BLOCKS` (10,000) lines. Once full, every line written had to drop the oldest one, and a `QTextEdit` took about 15 ms to do that. Measured here: 10,000 lines went in in 1.7 s, the next 2,000 took 30.8 s. At the pump's 256 messages per stream per 100 ms tick, a chatty run froze the IDE for seconds a tick, and draining the rest at the end of a long run (a verbose log, a tqdm bar whose `\r` becomes a new line) held it for minutes. Found in the review sweep of `show_code_window/`. +- **Fix**: the output is a `QPlainTextEdit`, Qt's widget for a capped log view, with the same cap, no-wrap, read-only setting and character formats. The same 2,000 lines past the cap: 0.28 s. +- **Tests**: `test_code_window.py` +2: 2,000 lines past the cap in under five seconds, with the newest kept and the cap held; the error colour still applies. The run window's 133 other tests pass unchanged. +- **Result / numbers**: `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/show_code_window/code_window.py` + - `test/test_utils/test_code_window.py` + - `architecture_explore.md` §4.5, §16, §18 +- **Open items**: none. + +## U-20260923-114 · 2026-09-23 · A run's output waits in a bounded queue, so a print loop no longer fills memory or floods Stop · #fix #executor + +- **What**: both executors carried output from the reader threads to the window in unbounded `Queue`s. A script printing in a loop (`while True: print(i)`) was read far faster than the window shows it, so the backlog grew in memory for as long as the run went on; the window's line cap bounds only the document. After Stop, the exit path drained the whole backlog into the window on the UI thread. Found in the review sweep of `show_code_window/`. +- **Fix** (`queue_pump.py`): + - `output_queue()` holds at most `MAX_QUEUED_MESSAGES` (10,000) pieces, and the executors use it. A reader waits on a full queue (`_put`, checking `keep_reading` every 0.2 s so it gives up once the run ends); the child's writes to its pipe wait with it. The run goes at the pace the window shows it, as in a terminal. + - Because a backlog can now outlast the exit, `ReaderGrace.still_reading(..., progressed=)` counts the two-second grace again after any tick that showed output, up to `MAX_DRAIN_SECONDS` (30 s) after the exit. The rest is shown, not taken for a process holding the pipes. + - `pump_message_queue` returns how many messages it took. +- **Tests**: + - `test_queue_pump.py` +6: a reader waits while the queue is full and then delivers every line in order; a waiting reader gives up once told to stop; the executors' queues are bounded; output still arriving keeps the grace open, but never past the drain limit; the pump reports its count. + - `test_run_output.py` +1: a print loop run for two seconds holds at most the bound, and Stop ends it with the exit line shown. +- **Result / numbers**: executor, queue and run window tests 57 passed, queue 22; 1689 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/extend/process_executor/queue_pump.py`, `python_task_process_manager.py`, `file_runner_process.py` + - `test/test_utils/test_queue_pump.py`, `test_run_output.py` + - `architecture_explore.md` §4.1, §4.5, §18 +- **Open items**: none. + +## U-20260923-115 · 2026-09-23 · The run window shows coloured and redrawn output as text, not escape codes · #fix #executor + +- **What**: `CodeWindow.append_output` wrote a child's output as it came, terminal control codes included. A script that colours its output (`colorama`, `rich`, `loguru`, `pytest --color=yes`, `FORCE_COLOR`) filled the window with `←[31m`; a progress bar's line clear and cursor moves (`ESC[2K`, `ESC[1G`), OSC titles and hyperlinks showed the same way, and a backspace, form feed or NUL showed as a box. Found in the review sweep of `show_code_window/`; reproduced. +- **Fix**: new `strip_terminal_codes()`, applied before `normalize_line_endings()`: it removes CSI sequences, OSC sequences (ended by BEL or `ESC \\`) and other two-byte escapes, then every C0 control character except tab, newline and carriage return, and DEL. Colours are not mapped to formats; the window keeps its own normal and error colours. +- **Tests**: `test_code_window.py` +8: SGR colours, a bare reset, an OSC title, an OSC 8 hyperlink, a line clear with a cursor move, backspace, form feed and NUL, and non-ASCII text left alone; a progress bar's carriage returns still break lines. +- **Result / numbers**: `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/show_code_window/code_window.py` + - `test/test_utils/test_code_window.py` + - `architecture_explore.md` §4.5, §18 +- **Open items**: none. + +## U-20260923-116 · 2026-09-23 · JupyterLab runs in the interpreter chosen in the IDE, finds itself without pip, and fails fast on a taken port · #fix #jupyter + +- **What**: three faults in the JupyterLab tab's launcher (`jupyter_lab_gui/jupyter_lab_thread.py`), found in the review sweep of `jupyter_lab_gui/`. + - The launcher never used the interpreter chosen in the IDE (`python_compiler`): it took the IDE's own when that ran in a venv, else a `venv`/`.venv` in the working directory, else it failed with "Cannot find venv python executable". Kernels ran in the wrong environment, and an IDE installed outside a venv could not start the lab. + - "Installed?" was `python -m pip show jupyterlab`. A venv made without pip (`uv venv`) answered no with jupyterlab installed, and the install that followed failed with "No module named pip". + - `find_free_port` let the port go before the server bound it, and jupyter_server's default `port_retries=50` moved to the next free port when it had been taken meanwhile. The tab kept polling the first one: a false startup timeout after 60 s, or, with something else listening there, that other service loaded in the view. +- **Fix**: + - New `choose_python(chosen)`: the interpreter chosen in the IDE, else a venv's (`get_venv_python`), else the IDE's own. `JupyterLabWidget(python_exe)` and `JupyterLauncherThread(python_exe=)` carry it, and the Tools tab entry passes the main window's `python_compiler`. + - `is_jupyter_installed` asks the interpreter whether it can find `jupyterlab` (`importlib.util.find_spec`). + - The server gets `--ServerApp.port_retries=0`: a taken port ends it at once, which the existing "exited early" path reports. The port is found and polled on `127.0.0.1` (`_LOOPBACK`). +- **Tests**: + - `test_jupyter_ready.py` +4: the chosen interpreter first, a venv's next; the IDE's own without a venv; "installed" asked of the interpreter, never pip, both ways; the server started with `port_retries=0`. + - The failure-text test injects its failure through `choose_python`, now that a missing venv is not one; the lifecycle stub launcher takes the new keyword. +- **Result / numbers**: JupyterLab tests 22 passed; 1701 tests in 107 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/jupyter_lab_gui/jupyter_lab_thread.py`, `jupyter_lab_widget.py`, `pybreeze/pybreeze_ui/menu/extend_jeditor_tab_menu/jupyter_lab_tab.py` + - `test/test_utils/test_jupyter_ready.py`, `test_jupyter_lifecycle.py` + - `architecture_explore.md` §10, §18 +- **Open items**: none. + +## U-20260923-117 · 2026-09-23 · A data folder the log creates is its owner's only, like the one pybreeze_data_dir creates · #fix #security + +- **What**: U-20260923-101 made `pybreeze_data_dir()` create `~/.pybreeze` as `0700` and leave an existing folder as it is. The log handler (`PyBreezeLogger._open`) made its own folder with `path.parent.mkdir(parents=True)`; the log is often the first thing written, so on POSIX it created `~/.pybreeze` with the default mode (usually `0755`), and the `0700` promise never applied. Files written with `private=True` stayed `0600`. Found in the review sweep of `utils/logging/`. +- **Fix**: when the log file lies under the data folder, `_open` creates that folder with `DATA_DIR_MODE` first, then `logs/`. +- **Tests**: `test_logger.py` +1: the data folder the log creates is made with mode `0700`, and `logs/` exists. +- **Result / numbers**: logger and app dirs tests 17 passed. `ruff check` clean. +- **Files**: + - `pybreeze/utils/logging/logger.py` + - `test/test_utils/test_logger.py` + - `architecture_explore.md` §18 +- **Open items**: none; this closes the findings of the `show_code_window/`, `jupyter_lab_gui/` and `utils/logging/` sweep. + +## U-20260923-118 · 2026-09-24 · Collect cyclic garbage on the GUI thread only, so a worker never destroys a Qt object · #fix #threading #crash + +- **What**: an intermittent access violation in `QCoreApplication::notifyInternal2`, seen in the unit tests as a crash inside `processEvents` (`test_install_menu.py`, `test_diff_gui.py`), about one run in three for the sorted files 21–30 plus `test_install_menu.py`. + - A bisect over the suspected range was misleading: the crash does not come from one commit, and a passing run proves nothing. + - Under WinDbg the crash read freed heap (`0xfeeefeee`) right after Qt's `QBasicTimer::stop: Failed. Possibly trying to stop from a different thread`. A Qt message handler that dumps every thread's stack at that warning showed the cause: the `DiffThread` worker, busy in `difflib`, was `Garbage-collecting`. + - CPython collects in whichever thread allocates past the threshold. A collection on a worker freed a Qt object the GUI thread had made, its destructor ran there and could not stop its timers, and the GUI thread later delivered a timer event to the freed object. + - The IDE has the same exposure: the Diff comparison, a run's output readers, the SSH and AI request threads all run Python and allocate. The same crash class is recorded in napari issue #1029 and PFRSentinel PR #32. +- **Fix**: + - New `pybreeze_ui/gui_thread_gc.py`: `GuiThreadGarbageCollector` turns automatic collection off and, from a 1 s `QTimer` on the GUI thread, collects each generation whose count is past the interpreter's own threshold. A threshold of 0 is never reached (Python 3.14 leaves the third at 0). `stop()` gives collection back. + - `start_editor()` installs it on the application (`collect_garbage_on_gui_thread`), so it covers the IDE only, not a host application that embeds the main window. + - New `test/test_utils/conftest.py` does the same for the suite, with a collection check after each test. +- **Tests**: `test_gui_thread_gc.py`, 8 tests: + - A cyclic Python-owned `QObject` is not freed by a worker that allocates 200,000 dicts, and is freed on the main thread by the next collection. Without the collector the worker frees it. + - Per-generation thresholds, a zero threshold, `stop()`, and `start_editor` installing the collector. +- **Result / numbers**: + - The crashing file set passed 6 of 6 runs, against 4 crashes in 5 runs before. + - Suite: 898 passed; 797 passed and 15 skipped. 1710 tests in 108 files; the new tests also pass on Python 3.14. + - Full collections on the GUI thread took 0.2–0.65 s at 320,000 objects under load. That pause is not new: under the GIL a collection on any thread holds up the GUI thread's Python. + - Both startup tests exit 0. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/gui_thread_gc.py`, `pybreeze/pybreeze_ui/editor_main/main_ui.py` + - `test/test_utils/conftest.py`, `test_gui_thread_gc.py` + - `architecture.md`, `architecture_explore.md` §16, §18, `CLAUDE.md` +- **Open items**: none. + +## U-20260923-119 · 2026-09-24 · Highlight TestPioneer scripts saved as .yaml, the other suffix its menu runs · #fix #syntax + +- **What**: the TestPioneer menu runs `.yml` and `.yaml` scripts, but `syntax_extend_package()` registered the TestPioneer keywords for `.yml` only, so a `.yaml` script opened with no highlighting. Found in the review sweep of `pybreeze_ui/syntax/`. +- **Fix**: + - New `TEST_PIONEER_SUFFIXES` in `syntax/syntax_keyword.py`. The keywords are registered under each suffix. + - The TestPioneer menu takes its suffix check and file-dialog filter from the same constant, so the two cannot drift apart again. +- **Tests**: new `test_syntax_extend.py`, 4 tests: + - Both suffixes carry the TestPioneer keywords. + - The menu's filter is built from the shared suffixes. + - Every package in `syntax_check_list` gets a `.json` keyword group. +- **Result / numbers**: + - TestPioneer menu tests: 10 passed. + - 1714 tests in 109 files. `ruff check` clean. +- **Also checked**: the highlight keywords compared with the executor tables of the packages installed in the local venv. Those packages are older than the ones the lists were written from, so the comparison says nothing; the lists are unchanged. +- **Files**: + - `pybreeze/pybreeze_ui/syntax/syntax_extend.py`, `syntax_keyword.py` + - `pybreeze/pybreeze_ui/menu/automation_menu/test_pioneer_menu/build_test_pioneer_menu.py` + - `test/test_utils/test_syntax_extend.py` + - `architecture_explore.md` §3, §5, §11, §18 +- **Open items**: none. + +## U-20260923-120 · 2026-09-24 · Answer No for an SSH host key nobody could be asked about, instead of the previous question's Yes · #fix #security #ssh + +- **What**: an unknown SSH host key could be trusted and stored without asking the user (`connect_gui/ssh/ssh_host_key_policy.py`, `HostKeyAsker`). Found in the review sweep of `connect_gui/ssh/`. + - `HostKeyAsker` keeps the answer on itself (`_answer`) and never reset it before a question. `ask()` returned whatever `_answer` held after the dialog. + - The panel that connected can be gone by the time the question arrives. A dock is deleted on close, and its connect thread runs on through `let_run_out`. `QMessageBox(parent)` then raises inside the blocking queued slot, `emit` returns anyway, and `ask()` returned the previous question's answer. + - After one Yes earlier in the session, the new host's key (possibly a man in the middle) was written to `~/.pybreeze/ssh_known_hosts` and trusted from then on. Reproduced against the previous code: `[True]` from the connecting thread. +- **Fix**: + - `ask()` sets `_answer = False` before every question. + - `_show` checks the parent with `shiboken6.isValid` and answers No, with a log line, when the panel is gone. +- **Tests**: `test_ssh_host_key_policy.py` +2: a question whose panel was deleted answers No after a previous Yes, asked on the UI thread and from a connecting thread. +- **Result / numbers**: host key tests 7 passed; 1716 tests in 109 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_host_key_policy.py` + - `test/test_utils/test_ssh_host_key_policy.py` + - `architecture_explore.md` §9, §18 +- **Open items**: the other findings from the same sweep are in progress.md. + +## U-20260923-121 · 2026-09-24 · Keep the SFTP tree's paths right after a folder rename, refresh the folder written to, and refuse names that are paths · #fix #ssh #done + +- **What**: progress #58 and #61, both in the SFTP tree's context menu (`connect_gui/ssh/ssh_file_viewer_widget.py`). + - Renaming a loaded folder updated only the folder's own item. Its children kept the old path, so a later download, delete or rename of one of them went to the old path: "No such file", or the wrong file once a new folder had taken the old name. + - New names were not checked. A `/` or `..` in a rename moved the entry into another folder while the tree still showed it in place. A leading `/` in a new folder's name made `posixpath.join` drop the folder it was created in. + - Create folder and upload started from a file item wrote into that file's folder, then refreshed the file item, which does nothing. The new entry showed up only after a manual refresh of the folder. +- **Fix**: + - New `plain_remote_name()` accepts one entry's name only: no `/`, NUL, `.` or `..`. Create folder and rename refuse anything else with a warning (new language key `ssh_file_viewer_dialog_message_bad_name`). + - A renamed folder whose children are loaded lists them again under its new path. + - New `folder_item()`: create folder and the upload's done step refresh the folder they wrote to. +- **Tests**: new `test_sftp_tree_actions.py`, 13 tests: + - Paths refused as names; a name kept stripped. + - Create folder and rename refusing a path. + - A renamed folder's children carrying the new path. + - A folder made from a file item appearing in that file's folder. + - `folder_item`. +- **Result / numbers**: SSH, SFTP and language tests: 123 passed. 1729 tests in 110 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_sftp_tree_actions.py` + - `progress.md` (#58, #61 removed), `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-122 · 2026-09-24 · Send a whole SSH command however long, and strip the escapes and controls the terminal still showed · #fix #ssh #done + +- **What**: progress #59 and #60, both in the SSH shell tab (`connect_gui/ssh/ssh_command_widget.py`). + - `send_command` ignored what `Channel.send` returned. That call sends at most one packet (about 32 KB with OpenSSH), so a longer pasted command lost its tail and its newline while the input box was cleared as if it had gone. It was also given a `str`, which paramiko counts in characters while the bytes that go out are UTF-8: a CJK command used about three times the window paramiko thought. + - `ANSI_ESCAPE_PATTERN` knew CSI, OSC and the Fe escapes only. `ESC ( B` (from `tput sgr0`, common in coloured prompts) showed as `(B`. `ESC 7` / `ESC 8` and `ESC =` / `ESC >` showed their second byte. A DCS answer showed its body. BEL and backspace stayed in the text. +- **Fix**: + - New `send_all(channel, data)`: sends the command's UTF-8 bytes with `Channel.sendall`. The otherwise non-blocking channel is given a 5 s timeout for that send only (`SEND_TIMEOUT_SECONDS`); a full window past it is reported as a send failure. + - The pattern now takes control strings (OSC, DCS, SOS, PM, APC) to BEL or ST, CSI, nF escapes (`ESC ( B`, `ESC # 8`), and every other two-byte escape. + - New `strip_terminal_controls()` applies each backspace to the character before it (never across a line break) and drops the rest of C0 but tab, newline and carriage return, and DEL. + - `_INCOMPLETE_ESCAPE` also holds back a control string cut between the ESC and the backslash of ST, and a character-set escape cut before its final byte. +- **Tests**: + - `test_ansi_escape.py` +12: the new escape forms, and backspace, BEL, NUL and DEL. + - `test_ssh_terminal_output.py` +4: + - A character-set or control-string escape cut at every position is still removed. + - A 60,006-byte command is sent whole as UTF-8 through a channel that takes one packet per send, with the timeout set and put back. +- **Result / numbers**: terminal tests 42 passed; 1745 tests in 110 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py` + - `test/test_utils/test_ansi_escape.py`, `test_ssh_terminal_output.py` + - `progress.md` (#59, #60 removed), `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-123 · 2026-09-24 · Open the SSH shell's channel on the connect thread, not the UI thread · #fix #ssh #threading #done + +- **What**: progress #56. The SSH connect ran on `SshConnectThread`, but the shell was opened afterwards on the UI thread (`connect_gui/ssh/ssh_command_widget.py`, `_on_connected` → `_start_shell` → `invoke_shell()`). Opening it waits on the server: `open_session` for up to paramiko's `channel_timeout` (3600 s), then `get_pty` and `invoke_shell` without a timeout. A server that went quiet right after authentication froze the IDE. +- **Fix**: + - New `open_shell_channel(client)` sets the keepalive, opens the shell and makes the channel non-blocking. It runs inside the connect callable on `SshConnectThread`, right after the connect, and the channel reaches the UI thread with `connected`. + - `_start_shell(channel, host, port, user)` only starts the reader and updates the labels. + - A shell that will not open fails the connect, reported and cleaned up the same way. +- **Tests**: `test_ssh_reentrancy.py`: + - The fake client opens a channel, and the off-UI-thread test asserts the channel was opened on the thread the connect ran on. + - +1: a shell the server refuses is reported like a failed connect, and the session is closed. +- **Result / numbers**: SSH and SFTP tests 118 passed; 1746 tests in 110 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py` + - `test/test_utils/test_ssh_reentrancy.py` + - `progress.md` (#56 removed), `architecture_explore.md` §9, §16, §18 +- **Open items**: none. + +## U-20260923-124 · 2026-09-24 · Run the SFTP menu's requests off the UI thread, and upload through a temporary name after asking before a replace · #fix #ssh #threading #done + +- **What**: progress #55 and #57, in the SFTP tree (`connect_gui/ssh/ssh_file_viewer_widget.py`). + - Create folder, rename and delete called `sftp.*` in the context menu's slot. `UI_WAIT_SECONDS` bounded only the wait for the session lock; an SFTP reply has no timeout, so on a stalled link the IDE froze until TCP gave up. + - An upload over a file of the same name replaced it without asking. `sftp.put` empties its target on open, so a dropped link or a Disconnect mid-transfer left the server's copy cut short. Downloads already avoid this with a `.part` file. +- **Fix**: + - New `SftpCallThread` runs one menu request. The tree's part (refresh the folder, show the new name, drop the removed item) follows on `done` unless the tree was cleared meanwhile; a failure shows the menu's usual error box. Requests still running when the tab closes are handed to `let_run_out`. + - `SFTPClientWrapper.upload(local, remote, replace=False)`: + - Within one hold of the session, it checks whether the target exists (`stat`). If it does and `replace` is false, it uploads nothing and returns `False`. + - Otherwise it `put`s to `...part` beside the target and moves that into place: `posix_rename`, or remove-then-rename on a server without the extension. + - A failure removes the temporary file and leaves the target as it was. + - `SftpTransferThread` reports an existing target on `exists`. The tree asks (new language keys `ssh_file_viewer_dialog_title_confirm_replace` / `..._message_confirm_replace`, No by default) and restarts with `replace=True` on Yes. +- **Tests**: + - `test_sftp_tree_actions.py` +2: create, rename and delete run on a worker and the tree follows; a refused delete is shown and leaves the item. + - New `test_sftp_upload.py`, 8 tests: + - A new file arrives whole through a temporary name. + - An existing file is untouched without `replace`, and replaced with it. + - A broken upload leaves the old file whole and nothing behind. + - A server without `posix_rename` still gets the file. + - The tree asks once and keeps the file on No, replaces it on Yes, and asks nothing for a new file. +- **Result / numbers**: SSH, SFTP and language tests: 138 passed. 1756 tests in 111 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `pybreeze/extend_multi_language/extend_english.py`, `extend_traditional_chinese.py` + - `test/test_utils/test_sftp_tree_actions.py`, `test_sftp_upload.py` + - `progress.md` (#55, #57 removed), `architecture_explore.md` §9, §16, §18 +- **Open items**: `ssh_file_viewer_widget.py` is now 1031 lines, past the 1000-line limit. The next commit moves the session and its threads to a module of their own. + +## U-20260923-125 · 2026-09-24 · Refactor: move the SFTP session and its threads out of the file tree's module · #refactor #ssh + +- **What**: `connect_gui/ssh/ssh_file_viewer_widget.py` had grown to 1031 lines with U-20260923-124, past the 1000-line limit per file. It held two things: the SFTP session with the threads that use it, and the tree widget. +- **Change** (structure only, no behaviour change): + - New `connect_gui/ssh/sftp_session.py` (427 lines) holds everything that is not the widget: + - `SFTPClientWrapper`, `SftpBusy`, `ConnectAbandoned`, `_remote_exists`, `_move_into_place` + - `SftpListThread`, `SftpTransferThread`, `SftpCallThread` + - the pure path helpers `natural_key`, `sort_entries`, `remote_join`, `plain_remote_name` + - `SSH_KEEPALIVE_SECONDS`, `UI_WAIT_SECONDS` + - `ssh_file_viewer_widget.py` (632 lines) keeps `SSHFileTreeManager`, `format_size`, `folder_item`, `LISTING_ROLE` and `UPLOAD_ASKED_WAIT_MS`, and imports the rest. + - `ssh_main_widget.py` still imports `SSHFileTreeManager` from the widget module. +- **Tests**: unchanged in substance. The tests that patched `apply_host_key_policy` or `paramiko.SSHClient` on the widget module, or imported the moved names from it, now use `sftp_session`: + - `test_ssh_reentrancy.py`, `test_sftp_remote_path.py`, `test_sftp_session_sharing.py`, `test_sftp_upload.py`, `test_ssh_teardown.py`, `test_fuzz_pure_logic.py`. +- **Result / numbers**: SSH, SFTP, fuzz and started-menu tests: 145 passed. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/sftp_session.py` (new), `ssh_file_viewer_widget.py` + - the six tests above + - `architecture_explore.md` §9 +- **Open items**: none. + +## U-20260923-126 · 2026-09-24 · Keep a Connect click from cutting an SFTP transfer short · #fix #ssh + +- **What**: the SSH tab's combined view has one Connect button for the shell and the file tree. + - Clicking it to bring back a shell that had closed also reconnected the file tree. + - `SFTPClientWrapper.connect` begins with `close()`, so a download or upload still running was cut short: a download's temporary file was dropped, an upload's was left on the server. + - The same happened with a Connect click in the standalone file tree. + - Found in the review sweep of `connect_gui/ssh/`. +- **Fix**: `SSHFileTreeManager._connect` refuses while a transfer runs and says so, with the message a second transfer already gets. The check is one helper, `_refused_while_transferring()`, shared with `_start_transfer`. The shell side still reconnects. +- **Tests**: `test_ssh_teardown.py` +1: with a download running, Connect starts no connect and tells the user. +- **Result / numbers**: SSH and SFTP tests 129 passed; 1757 tests in 111 files. `ruff check` clean. +- **Files**: + - `pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py` + - `test/test_utils/test_ssh_teardown.py` + - `architecture_explore.md` §9, §18 +- **Open items**: none. + +## U-20260923-127 · 2026-09-24 · Refactor: drop the executor's exception handlers that nothing could reach · #refactor #executor + +- **What**: `build_process` caught `json.JSONDecodeError` and `ITETestExecutorException`, and `build_process_from_file` caught `ITETestExecutorException` (`extend/process_executor/process_executor_utils.py`). Nothing inside either `try` raises them. The script goes to the package as text and is parsed in the child, and nothing in PyBreeze raises `ITETestExecutorException`. The handlers were unreachable. Found in the review sweep of `extend/process_executor/`. +- **Change** (no behaviour change): both handlers are removed, along with the imports only they used (`json`, `wrong_test_data_format_exception_tag`, `ITETestExecutorException`). The batch runner's docstring now says what reports a file's failure (its run window, a run that cannot start included). +- **Tests**: unchanged. Executor tests (`test_build_task_process`, `test_closing_the_ide`, `test_install_menu`, `test_prthinker_process`, `test_run_folder`, `test_run_output`): 50 passed. `ruff check` clean. +- **Files**: `pybreeze/extend/process_executor/process_executor_utils.py` +- **Open items**: none. + +## U-20260923-128 · 2026-09-24 · Let an image on the diagram canvas be resized by its corner handles · #fix #diagram + +- **What**: dragging a corner handle of an image on the diagram canvas raised `AttributeError: 'DiagramImage' object has no attribute 'node_w'` on the press and on every mouse move (`diagram_editor/diagram_items.py`, `ResizeHandle.mousePressEvent`). The handle read the node's `node_w` / `node_h`, but images get the same handles and keep their size in `img_w` / `img_h`. The image never resized and nothing was recorded for undo; only the property panel's spin boxes could resize one. Found in the review sweep of `diagram_editor/`. +- **Fix**: the handle takes the size from `rect()`, which is `(0, 0, w, h)` for nodes and images alike. +- **Tests**: `test_diagram_images.py` +1: dragging an image's bottom-right handle by (50, 50) makes it 150 x 130, as one undo step. +- **Result / numbers**: diagram image tests 14 passed. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_images.py`, `architecture_explore.md` §18 +- **Open items**: the review's other findings are in progress.md. + +## U-20260923-129 · 2026-09-24 · Skip diagram items a file places nowhere, keep far ones within reach, and leave nodes alone when a connection cannot be built · #fix #diagram #done + +- **What**: progress #64 and #66, in `diagram_editor/diagram_items.py`. + - `x` / `y` from a `.diagram.json` went straight to `setPos`, while sizes, z, colours and fonts were already checked. `"x": NaN` (which `json.loads` accepts) made an item invisible and unselectable, and the scene's bounding rect NaN. Every PNG/SVG export then failed with "Could not export", and a save wrote `NaN` back out. `1e308` made the export's image size infinite, with the same result. + - `DiagramConnection.__init__` added itself to both nodes' `connections` before building its label. A label that is not text raised there, so the load skipped the connection but both nodes kept it: every move of either node updated a connection that was not in the scene, and deleting one warned. +- **Fix**: + - New `_coordinate()`: a position must be a finite number, or the entry is skipped like any other malformed one, and it is clamped to `MAX_COORDINATE` (1,000,000) from the origin. + - `_number()` also falls back for NaN and infinities. Nodes' and images' sizes read from a file go through it, so a non-number size gets the default instead of failing the entry. + - A connection registers on its nodes only after everything else in its constructor has succeeded. +- **Tests**: `test_diagram_serialization.py` +5: + - A node and an image at `NaN`, `inf` or `"left"` are skipped, and the bounding rect stays finite. + - `1e308` is brought to the limit. + - A connection labelled `5` leaves both nodes with no connections. +- **Result / numbers**: diagram tests 89 passed; 1763 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `test/test_utils/test_diagram_serialization.py`, `progress.md` (#64, #66 removed), `architecture_explore.md` §7, §18 +- **Open items**: none. + +## U-20260923-130 · 2026-09-24 · Keep diagram images and nodes stacked as they were across save, load and undo · #fix #diagram #done + +- **What**: progress #62, stacking between the diagram's images and nodes (`diagram_editor/diagram_items.py`, `diagram_scene.py`). + - Images saved no `z`, and a load added every node before every image. Items of equal z stack in the order they are added, so after a save and reopen, or after any undo or redo (undo rebuilds the scene from a snapshot), an image came back above every node, including one drawn over it. + - Bring to Front and Send to Back (`_change_z`) only looked at nodes. With one node and an image there was nothing to go above, and the node stayed under the image. +- **Fix**: + - Images save and restore `z`. + - `to_dict` gives every node and image its place among all of them, bottom first (`"stack"`). After loading, `_restore_stacking()` adds them again in that order. A file without `stack` loads as before. + - `_change_z` moves selected nodes and images above or below every other node and image. +- **Tests**: `test_diagram_serialization.py` +4: + - An image under a node stays under it after a reload, and after an unrelated undo. + - Bring to Front puts a lone node above an image. + - A file without stacking loads in the old order. +- **Result / numbers**: diagram tests 93 passed; 1767 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_items.py`, `diagram_scene.py`, `test/test_utils/test_diagram_serialization.py`, `progress.md` (#62 removed), `architecture_explore.md` §7, §18 +- **Open items**: none. + +## U-20260923-131 · 2026-09-24 · Read capitalised keywords as nodes, class suffixes, open-link chains, percent signs and quoted edge labels right in the Mermaid import · #fix #diagram #mermaid #done + +- **What**: progress #63, the Mermaid import (`diagram_editor/diagram_mermaid_parser.py`). + - `_SKIP_RE` was case-insensitive. A node named `End` (the Mermaid docs suggest that spelling to get round the `end` keyword), `Click` or `Style` was taken for a keyword and its line skipped: `End((Finish))` lost its shape and label, `End --> C` dropped the edge and C, and `Style[Style guide] --> B` gave no nodes at all. + - A `:::class` suffix stopped the shape from matching, so `A[Label]:::hot` got the label `A`. A later bare `A:::hot` overwrote a label declared earlier. + - The inline-label rewrite matched the last two dashes of `---`, so `A --- B --- C` became one edge A→C labelled B. + - `%%` started a comment anywhere, so `A["100%% done"] --> B` lost the edge and B. + - A quoted edge label (`-->|"quoted label"|`) kept its quotes. +- **Fix**: + - The keywords match in lower case only. + - A trailing `:::name` is stripped before the shape is read, so a class-only suffix is a bare reference. + - The label rewrites match only a `--` or `==` that starts a link. + - Comments are looked for outside quotes and brackets (`_protect` first). + - Edge labels are unquoted. +- **Tests**: `test_mermaid_parser.py` +6: capitalised keywords as nodes, lower-case keywords still skipped, class suffixes, a chain of open links, percent signs in a label with trailing and whole-line comments, a quoted edge label. +- **Result / numbers**: parser and fuzz tests 90 passed; 1773 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/pybreeze_ui/diagram_editor/diagram_mermaid_parser.py`, `test/test_utils/test_mermaid_parser.py`, `progress.md` (#63 removed), `architecture_explore.md` §18 +- **Open items**: none. + +## U-20260923-132 · 2026-09-24 · Export a diagram to PNG or SVG through a file beside the target, so a failed export leaves the previous one · #fix #diagram #done + +- **What**: progress #65. The diagram editor's PNG and SVG exports wrote straight onto the chosen file (`diagram_editor/diagram_editor_widget.py`). `QSvgGenerator` empties its file as soon as painting starts, and `painter.end()` was not checked. Re-exporting over an existing file when the write failed part-way (a full disk, a locked file) left the previous export truncated or empty, and for SVG with no warning. +- **Fix**: + - New `replace_written(path, write)` in `utils/file_process/replace_file.py`, next to `replace_text`. The writer is given `.saving` beside the file (same extension, so a writer that goes by it picks the same format); that file takes the target's place only when complete, and a failure removes it and leaves the target as it was. + - The PNG export saves through `_save_png`, which raises when `QImage.save` returns False. + - The SVG export renders through `_write_svg`, which raises when the painter cannot start or `end()` fails. + - A failure shows the existing "Could not export" warning. +- **Tests**: + - `test_diagram_editor_widget.py` +4: a PNG export writes a PNG, an SVG export writes an SVG, and a failed export of either keeps the previous file whole with nothing left beside it. + - `test_replace_file.py` +2: the writer gets a file with the same extension, and a failing writer leaves the old file and nothing else. +- **Result / numbers**: diagram widget and replace-file tests pass; 1779 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/file_process/replace_file.py`, `pybreeze/pybreeze_ui/diagram_editor/diagram_editor_widget.py`, `test/test_utils/test_diagram_editor_widget.py`, `test_replace_file.py`, `progress.md` (#65 removed), `CLAUDE.md` (File I/O), `architecture_explore.md` §7, §12, §18 +- **Open items**: none. + +## U-20260923-133 · 2026-09-24 · Read ISO-8601 the same on every Python, epoch microseconds and nanoseconds, and eight-digit dates in the timestamp converter · #fix #tools #done + +- **What**: progress #67, the timestamp converter (`utils/timestamp_tools/timestamp_converter.py`). + - Before Python 3.11, `datetime.fromisoformat` took only what `isoformat()` writes; the converter only turned a trailing `Z` into `+00:00`. On 3.10, which CI runs, `+0000`, `+08`, fractions of 1, 2 or 9 digits (Go and Kubernetes write 9) and the basic `20240101T000000Z` were refused. A lowercase `z` was refused on every version. + - Unit detection knew seconds and milliseconds only. Microseconds (`time.time_ns() // 1000`) and nanoseconds (`time.time_ns()`, Go's `UnixNano`) were divided by 1000 and overflowed. + - `20240101` was read as seconds: 1970-08-23. +- **Fix**: + - New `_ISO_RE` / `_iso_match()` parse a date or date-time the same way on every version: basic or extended format, `Z`, `z`, `±HH`, `±HHMM` or `±HH:MM`, and any number of fraction digits (kept to the microsecond). Anything else still goes to `fromisoformat`, so 3.11+ keeps the rest of what it reads. + - `detect_epoch_unit` tells s, ms, µs and ns apart by magnitude (thresholds 1e11, 1e14, 1e17). A whole number is parsed with `int()` and divided exactly to the microsecond, rounding toward the past. + - Eight digits that make a valid date are read as `YYYYMMDD`; eight that do not (`12345678`) are still seconds. +- **Tests**: `test_timestamp_converter.py` +13: + - six offset and designator forms, three fraction lengths; + - µs and ns epochs; + - an eight-digit date, and one that is not a date; + - month 13 refused. + The file passes on Python 3.10 (uv), 3.11 and 3.14. +- **Result / numbers**: 1792 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/timestamp_tools/timestamp_converter.py`, `test/test_utils/test_timestamp_converter.py`, `progress.md` (#67 removed), `README.md` (Timestamp Converter), `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-134 · 2026-09-24 · Decode a JWT pasted with Bearer, quotes or line breaks around it, and refuse characters outside base64url · #fix #tools #done + +- **What**: progress #69, the JWT decoder (`utils/jwt_tools/jwt_decoder.py`, used by the JWT tab and the response inspector). + - The tab handed the pasted text to `decode_jwt` as it was. `Bearer eyJ...`, copied from an `Authorization` header, always failed. + - A token copied with quotes or wrapped over lines decoded or failed depending on the header's length. `urlsafe_b64decode` is not strict: it drops characters outside the alphabet after the padding has been worked out from a length that counted them. `{"alg":"none"}` and `{"alg":"ES256","kid":"a"}` failed, while HS256 and RS256 headers happened to work. A quoted token that did decode showed its signature with the quote on it. +- **Fix**: + - New `_the_token()`: whitespace inside the text is removed. If what is left is not a bare token, the first JWT-looking token in it (`find_tokens`) is decoded. + - Segments are decoded strictly (`b64decode(..., altchars=b"-_", validate=True)`), so a character outside base64url is refused instead of dropped. +- **Tests**: `test_jwt_decoder.py` +14: three headers, each bare, after `Bearer `, in quotes and in an `Authorization` line; a token wrapped over lines; a segment with a character outside base64url refused. +- **Result / numbers**: JWT tests 36 passed; 1806 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/jwt_tools/jwt_decoder.py`, `test/test_utils/test_jwt_decoder.py`, `progress.md` (#69 removed), `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-135 · 2026-09-24 · Read the final response of curl -i output in the response inspector, and a one-line body as a body · #fix #tools #done + +- **What**: progress #68, the response inspector (`utils/response_inspector/response_analyzer.py`). + - `curl -i` prints every response it read: `HTTP/1.1 100 Continue`, a proxy's `200 Connection established`, and each redirect when `-L` follows one. The inspector took the first status line. The real response became its body, so its status and headers were lost and a JSON body was not recognised. + - A body pasted on its own whose one line has a colon (`Error: invalid token supplied`) was shown as a header with an empty body. +- **Fix**: + - `_parse_head_and_body` parses one response (`_parse_one_response`), and while the body starts with another status line, parses again from there. The final response is the one reported. + - A single line with no status line is the body. + - A header block pasted without a status line is still read as headers. +- **Tests**: `test_response_analyzer.py` +5: + - The final response is read after `100 Continue`, `Connection established` and a `301`. + - A one-line body stays a body. + - A header block stays headers. +- **Result / numbers**: response analyzer tests 29 passed; 1811 tests in 111 files. `ruff check` clean. +- **Files**: `pybreeze/utils/response_inspector/response_analyzer.py`, `test/test_utils/test_response_analyzer.py`, `progress.md` (#68 removed), `architecture_explore.md` §6, §18 +- **Open items**: none. + +## U-20260923-136 · 2026-09-24 · Show AI review answers as text, cut long error pages, save review totals in one step, and name only a redirect's host · #fix #ai #security + +- **What**: four findings from the review sweep of the AI panels. + - The AI review panel (`connect_gui/url/ai_code_review_gui.py`) showed the answer with `QTextEdit.append`, which reads text that looks like markup as rich text. An answer about HTML or JSX lost its tags and a whole ` instead of here' + + client.on_answered(answer) + client.accept_response() + + text = client.response_panel.toPlainText() + assert text.startswith(answer) + assert " str: @@ -36,3 +36,69 @@ def test_csi_and_fe(self, raw, expected): def test_osc_sequences_are_stripped(self, raw, expected): # Regression: OSC bodies (e.g. "0;title") used to leak through as garbage. assert _strip(raw) == expected + + @pytest.mark.parametrize("raw,expected", [ + # tput sgr0 in many coloured prompts: a character-set escape, then SGR + ("\x1b(B\x1b[mhello", "hello"), + ("\x1b#8x", "x"), # nF with another intermediate + ("a\x1b7b\x1b8c", "abc"), # save / restore cursor + ("\x1b=\x1b>keypad", "keypad"), # keypad modes + ("\x1bcreset", "reset"), # full reset (Fs) + ("\x1bP1$r0m\x1b\\after", "after"), # DCS answer, ST terminated + ("\x1b_app\x1b\\x", "x"), # APC + ]) + def test_other_escapes_leave_no_text(self, raw, expected): + # They showed as "(B", "7", "=", or the DCS body + assert _strip(raw) == expected + + +class TestControlCharacters: + @pytest.mark.parametrize("raw,expected", [ + ("bell\x07", "bell"), + ("abc\x08\x08d", "ad"), # backspace takes the character back + ("line\n\x08next", "line\nnext"), # never across a line break + ("tab\there\r\n", "tab\there\r\n"), # tab and line ends stay + ("x\x00y\x7fz", "xyz"), + ]) + def test_controls_are_applied_or_dropped(self, raw, expected): + from pybreeze.utils.terminal_text import strip_terminal_controls + + assert strip_terminal_controls(raw) == expected + + +def _backspaced_pair_by_pair(text: str) -> str: + """How backspaces were applied before: a character and its backspace removed, pass after pass.""" + import re + + pair = re.compile("[^\n\x08]\x08") + while True: + applied = pair.sub("", text) + if applied == text: + return text + text = applied + + +def test_backspaces_are_applied_as_before(): + from hypothesis import given, settings + from hypothesis import strategies as st + + from pybreeze.utils.terminal_text import _apply_backspaces + + @settings(max_examples=500, deadline=None) + @given(st.text(alphabet="ab\n\x08", max_size=40)) + def same(text): + assert _apply_backspaces(text) == _backspaced_pair_by_pair(text) + + same() + + +def test_a_long_rub_out_is_quick(): + # Pair by pair, 10,000 characters and their 10,000 backspaces took 5 s on the UI thread + import time + + from pybreeze.utils.terminal_text import strip_terminal_controls + + started = time.perf_counter() + + assert strip_terminal_controls("a" * 10_000 + "\x08" * 10_000 + "done") == "done" + assert time.perf_counter() - started < 1 diff --git a/test/test_utils/test_answered_boxes_are_deleted.py b/test/test_utils/test_answered_boxes_are_deleted.py new file mode 100644 index 00000000..a1b521bf --- /dev/null +++ b/test/test_utils/test_answered_boxes_are_deleted.py @@ -0,0 +1,61 @@ +"""A message box or dialog made for the main window is deleted once answered. + +Built with the main window as its parent and never deleted, each one stayed a +child of the window until the IDE exited, one more every time it was shown. +""" +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 + +import pybreeze # noqa: E402 + +_BOX = re.compile(r"^(\s*)(\w+) = (?:QMessageBox|PRThinkerSettingDialog)\(", re.M) + + +@pytest.fixture(scope="module") +def app(): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def test_every_box_built_in_the_package_is_deleted_on_close(): + missing = [] + for path in Path(pybreeze.__file__).parent.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for match in _BOX.finditer(text): + following = text[match.end():].split("\n", 3)[:3] + wanted = f"{match.group(2)}.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)" + if not any(wanted in line for line in following): + missing.append(f"{path.name}: {match.group(2)}") + assert not missing, f"Boxes kept after they are answered: {missing}" + + +def test_an_answered_about_box_is_gone(app): + from PySide6.QtCore import QCoreApplication, QEvent, QTimer + from PySide6.QtWidgets import QApplication, QWidget + + from pybreeze.pybreeze_ui.menu.plugin_menu import build_plugin_menu + + window = QWidget() + + def answer() -> None: + QApplication.activeModalWidget().accept() + + QTimer.singleShot(50, answer) + build_plugin_menu._make_about_callback(window, "Go", "1", "someone")() + # Only the box's delete: others still posted belong to earlier tests + for child in window.children(): + QCoreApplication.sendPostedEvents(child, QEvent.Type.DeferredDelete) + + assert window.children() == [] diff --git a/test/test_utils/test_app_dirs.py b/test/test_utils/test_app_dirs.py index c5f66cb0..0a051917 100644 --- a/test/test_utils/test_app_dirs.py +++ b/test/test_utils/test_app_dirs.py @@ -18,3 +18,19 @@ def test_idempotent_when_already_exists(self, tmp_path, monkeypatch): second = pybreeze_data_dir() assert first == second assert second.is_dir() + + def test_it_is_made_for_its_owner_only(self, tmp_path, monkeypatch): + # It holds the SSH known hosts and the prthinker keys and tokens + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + modes: list = [] + made = Path.mkdir + + def record(path, mode=0o777, parents=False, exist_ok=False): + modes.append(mode) + made(path, mode=mode, parents=parents, exist_ok=exist_ok) + + monkeypatch.setattr(Path, "mkdir", record) + + pybreeze_data_dir() + + assert modes == [0o700] diff --git a/test/test_utils/test_autocontrol_record.py b/test/test_utils/test_autocontrol_record.py new file mode 100644 index 00000000..7b4e5c67 --- /dev/null +++ b/test/test_utils/test_autocontrol_record.py @@ -0,0 +1,89 @@ +"""AutoControl's Record Stop: recording stops from any tab, and what it gives back can be run.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox, QPlainTextEdit, QTabWidget, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.auto_control_menu import build_autocontrol_menu as record_menu + +_RECORDED = [["AC_type_keyboard", {"keycode": 65}], + ["mouse_left", {"mouse_keycode": "mouse_left", "x": 10, "y": 20}]] + + +class EditorTab(QWidget): + """Stands in for a JEditor editor tab.""" + + def __init__(self) -> None: + super().__init__() + self.code_edit = QPlainTextEdit() + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def window(app, monkeypatch): + monkeypatch.setattr(record_menu, "EditorWidget", EditorTab) + made = QMainWindow() + made.tab_widget = QTabWidget() + made.told = [] + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: made.told.append(a[2]))) + yield made + made.deleteLater() + + +def _recording(monkeypatch, actions) -> list: + stopped: list = [] + + def stop(): + stopped.append(True) + return actions + + monkeypatch.setattr(record_menu.je_auto_control, "stop_record", stop) + return stopped + + +def test_the_recording_goes_in_as_json_the_runner_reads(window, monkeypatch): + _recording(monkeypatch, _RECORDED) + tab = EditorTab() + window.tab_widget.addTab(tab, "script.json") + + record_menu.stop_record(window) + + # It went in as str(list): single quotes, which json.loads refuses. + assert json.loads(tab.code_edit.toPlainText()) == _RECORDED + + +def test_recording_stops_whatever_tab_is_in_front(window, monkeypatch): + stopped = _recording(monkeypatch, _RECORDED) + window.tab_widget.addTab(QWidget(), "a tool tab") + + record_menu.stop_record(window) + + # From a tool tab it used to stop nothing, and the hooks kept recording. + assert stopped == [True] + assert json.loads(QApplication.clipboard().text()) == _RECORDED + assert window.told + + +@pytest.mark.parametrize("nothing", [None, []]) +def test_nothing_recorded_says_so_and_inserts_nothing(window, monkeypatch, nothing): + _recording(monkeypatch, nothing) + tab = EditorTab() + window.tab_widget.addTab(tab, "script.json") + + record_menu.stop_record(window) + + # It used to insert the text "None". + assert tab.code_edit.toPlainText() == "" + assert window.told diff --git a/test/test_utils/test_automation_menu_factory.py b/test/test_utils/test_automation_menu_factory.py new file mode 100644 index 00000000..5db1c790 --- /dev/null +++ b/test/test_utils/test_automation_menu_factory.py @@ -0,0 +1,126 @@ +"""The automation menu factory: every entry it builds is still there, and still answers, later. + +A QAction added to a menu is not owned by it. One kept only in a local variable +is deleted when the builder returns, and its entry disappears from the menu, so +each test collects garbage before looking. +""" +from __future__ import annotations + +import gc +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QApplication, QMainWindow, QMenu, QTabWidget, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu import automation_menu_factory +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import ( + AutomationMenu, HelpLink, RunAction, build_automation_menu +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeWindow(QMainWindow): + """A main window with the members the factory touches.""" + + def __init__(self): + super().__init__() + self.automation_menu = self.menuBar().addMenu("Automation") + self.tab_widget = QTabWidget() + + +@pytest.fixture +def window(app): + fake = FakeWindow() + yield fake + fake.deleteLater() + + +def _submenu_actions(menu: QMenu, index: int) -> list[QAction]: + # Hold the submenu's own action while asking it for the submenu: PySide + # deletes the submenu along with a temporary wrapper of that action. + submenu_action = menu.actions()[index] + return submenu_action.menu().actions() + + +def test_run_actions_survive_and_answer(window): + calls = [] + menu = build_automation_menu(window, AutomationMenu( + "run_label", + run_actions=( + RunAction("run_label", lambda: calls.append("first")), + RunAction("help_label", lambda: calls.append("second")), + ), + )) + gc.collect() + + run_actions = _submenu_actions(menu, 0) + assert len(run_actions) == 2 + run_actions[1].trigger() + assert calls == ["second"] + + +def test_help_actions_survive_and_open_their_page(window, monkeypatch): + opened = [] + monkeypatch.setattr( + automation_menu_factory, "open_web_browser", + lambda ui, url, label: opened.append(url)) + menu = build_automation_menu(window, AutomationMenu( + "run_label", + help_links=( + HelpLink("https://docs.example", "help_label", "help_label"), + HelpLink("https://github.example", "run_label", "run_label"), + ), + )) + gc.collect() + + help_actions = _submenu_actions(menu, 0) + assert len(help_actions) == 2 + for action in help_actions: + action.trigger() + assert opened == ["https://docs.example", "https://github.example"] + + +def test_project_and_gui_actions_survive_and_answer(window): + created = [] + menu = build_automation_menu(window, AutomationMenu( + "run_label", + create_project=lambda: created.append(True), + create_project_label_key="project_label", + gui_widget_class=QWidget, gui_label="GUI", + )) + gc.collect() + + project_actions = _submenu_actions(menu, 0) + assert len(project_actions) == 1 + project_actions[0].trigger() + assert created == [True] + + gui_action = menu.actions()[1] + assert gui_action.text() == "GUI" + gui_action.trigger() + assert window.tab_widget.count() == 1 + + +def test_every_create_project_names_a_package_that_exists(): + # Found by finding the package, not importing it: some of these write a log + # into the working directory as they import. + import importlib.util + import re + from pathlib import Path + + menus = Path(__file__).resolve().parents[2] / "pybreeze" / "pybreeze_ui" / "menu" / "automation_menu" + names = [name for path in menus.rglob("*.py") + for name in re.findall(r'safe_create_project\(\w+, "([^"]+)"\)', path.read_text(encoding="utf-8"))] + + assert names, "no create-project entries found" + assert [name for name in names if importlib.util.find_spec(name) is None] == [] diff --git a/test/test_utils/test_build_task_process.py b/test/test_utils/test_build_task_process.py new file mode 100644 index 00000000..7a3237ce --- /dev/null +++ b/test/test_utils/test_build_task_process.py @@ -0,0 +1,314 @@ +"""The run window an automation run opens: which interpreter it runs with, and where it is kept.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if app is None: + try: + app = QApplication([]) + except Exception as exc: # pragma: no cover - no usable Qt platform + pytest.skip(f"Cannot start QApplication: {exc}") + return app + + +class MainWindow: + """The little of the IDE's main window that opening a run window touches.""" + + def __init__(self, python_compiler=None) -> None: + self.python_compiler = python_compiler + self.current_run_code_window: list = [] + self.encoding = "utf-8" + self.cleared = False + + def clear_code_result(self) -> None: + self.cleared = True + + +class TestBuildTaskProcess: + def test_the_interpreter_chosen_in_the_ide_is_used(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + process = build_task_process(MainWindow("C:/envs/py312/python.exe")) + + assert process.renew_path() is True + assert process.compiler_path == "C:/envs/py312/python.exe" + + def test_without_a_choice_the_working_directory_is_searched(self, qt_app, monkeypatch, tmp_path): + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + searched: list = [] + monkeypatch.setattr(manager_module, "find_venv_path", lambda: tmp_path) + monkeypatch.setattr( + manager_module, "check_and_choose_venv", + lambda path: searched.append(path) or "found/python") + + process = build_task_process(MainWindow()) + + assert process.renew_path() is True + assert searched == [tmp_path] + assert process.compiler_path == "found/python" + + def test_without_a_venv_the_ide_s_own_interpreter_runs_it(self, qt_app, monkeypatch, tmp_path): + # PATH came first: on Windows often the Store's python3 stub, exit 9009 and nothing said + import sys + + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + monkeypatch.setattr(manager_module, "find_venv_path", lambda: tmp_path / "no-venv") + monkeypatch.setattr(manager_module, "check_and_choose_venv", lambda path: "from/PATH/python3") + + process = build_task_process(MainWindow()) + + assert process.renew_path() is True + assert process.compiler_path == sys.executable + + def test_a_packaged_ide_still_looks_on_path(self, qt_app, monkeypatch, tmp_path): + # Its sys.executable is the app, not a Python + import sys + + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(manager_module, "find_venv_path", lambda: tmp_path / "no-venv") + monkeypatch.setattr(manager_module, "check_and_choose_venv", lambda path: "from/PATH/python3") + + assert manager_module.default_interpreter() == "from/PATH/python3" + + def test_the_run_window_is_kept_and_the_old_result_cleared(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + main_window = MainWindow() + process = build_task_process(main_window) + + assert main_window.current_run_code_window == [process.main_window] + assert main_window.cleared + + def test_the_report_mail_is_sent_only_when_asked(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + assert build_task_process(MainWindow()).task_done_trigger_function is None + assert callable(build_task_process(MainWindow(), send_mail=True).task_done_trigger_function) + + +class TestTheReportMailSaysHowItWent: + """The run window tells the user whether the report went out; it was only logged.""" + + @staticmethod + def _run_the_hook(qt_app, monkeypatch, answer): + import time + + from pybreeze.extend.mail_thunder_extend import mail_thunder_setting as mail + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + asked: list = [] + + def send_report(path, *, not_before=None): + asked.append((path, not_before)) + return answer + + monkeypatch.setattr(mail, "send_report", send_report) + before = time.time() + runner = build_task_process(MainWindow(), send_mail=True) + runner.task_done_trigger_function() + window = runner.main_window + deadline = time.monotonic() + 10 + while "[Mail]" not in window.code_result.toPlainText() and time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.01) + return window.code_result.toPlainText(), asked, before + + def test_a_sent_report_is_reported(self, qt_app, monkeypatch): + text, asked, before = self._run_the_hook(qt_app, monkeypatch, None) + + assert "[Mail] The test report was sent" in text + ((path, not_before),) = asked + # The report the child writes in the directory it starts in, from this run on + assert os.path.isabs(path) and path.endswith("default_name.html") + assert not_before >= before + + def test_a_report_not_sent_says_why(self, qt_app, monkeypatch): + text, _asked, _before = self._run_the_hook(qt_app, monkeypatch, "no mail user is set") + + assert "[Mail] The test report was not sent: no mail user is set" in text + + def test_an_answer_after_the_window_is_gone_is_dropped(self, qt_app, monkeypatch): + import threading + + from pybreeze.extend.mail_thunder_extend import mail_thunder_setting as mail + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + from shiboken6 import delete + + release = threading.Event() + raised: list = [] + monkeypatch.setattr(threading, "excepthook", raised.append) + monkeypatch.setattr(mail, "send_report", lambda _path, *, not_before=None: release.wait(5) and None) + runner = build_task_process(MainWindow(), send_mail=True) + runner.task_done_trigger_function() + (mail_thread,) = [one for one in threading.enumerate() if one.name == "pybreeze-report-mail"] + delete(runner.main_window) + release.set() + + mail_thread.join(5) + qt_app.processEvents() + assert not mail_thread.is_alive() + assert raised == [] + + +class TestRunningWithoutAScriptTab: + """A run takes the code from the editor tab in front; with another tab there it says so.""" + + def test_a_non_editor_tab_is_reported_in_a_run_window(self, qt_app, monkeypatch): + from PySide6.QtWidgets import QTabWidget, QWidget + + from pybreeze.extend.process_executor import process_executor_utils + + started: list = [] + monkeypatch.setattr( + process_executor_utils, "start_process", + lambda *args, **kwargs: started.append(args)) + main_window = MainWindow() + main_window.tab_widget = QTabWidget() + main_window.tab_widget.addTab(QWidget(), "tools") + + process_executor_utils.build_process(main_window, "je_api_testka") + + assert started == [] + run_window = main_window.current_run_code_window[0] + assert "je_api_testka" in run_window.code_result.toPlainText() + assert "editor tab in front" in run_window.code_result.toPlainText() + + def test_a_script_given_outright_runs_without_any_tab(self, qt_app, monkeypatch): + from PySide6.QtWidgets import QTabWidget + + from pybreeze.extend.process_executor import process_executor_utils + + started: list = [] + monkeypatch.setattr( + process_executor_utils, "start_process", + lambda *args, **kwargs: started.append(args)) + main_window = MainWindow() + main_window.tab_widget = QTabWidget() + + process_executor_utils.build_process(main_window, "je_api_testka", exec_str="{}") + + assert started and started[0][2] == "{}" + assert main_window.current_run_code_window == [] + + +class TestLettingGoOfARunWindow: + """The main window holds every run window; a closed one whose run is over goes.""" + + def test_a_closed_window_with_nothing_running_is_let_go_of(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + main_window = MainWindow() + run_window = open_run_window(main_window) + + run_window.close() + + assert main_window.current_run_code_window == [] + + def test_a_window_whose_run_is_still_going_is_kept(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + class StillRunning: + class process: + @staticmethod + def poll(): + return None + + main_window = MainWindow() + run_window = open_run_window(main_window) + run_window.runner = StillRunning() + + run_window.close() + + assert main_window.current_run_code_window == [run_window] + + def test_closing_one_window_does_not_skip_the_others(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + main_window = MainWindow() + windows = [open_run_window(main_window) for _ in range(3)] + + for window in tuple(main_window.current_run_code_window): + window.close() + + assert main_window.current_run_code_window == [] + assert all(not window.isVisible() for window in windows) + + def test_a_window_let_go_of_is_freed(self, qt_app): + # The slot that let go of it held the window, and the connection + # belongs to the window: every closed run window, its output and its + # executor stayed alive for as long as the IDE ran + import gc + import weakref + + from pybreeze.extend.process_executor.process_executor_utils import open_run_window + + main_window = MainWindow() + run_window = open_run_window(main_window) + watch = weakref.ref(run_window) + + run_window.close() + del run_window + gc.collect() + + assert main_window.current_run_code_window == [] + assert watch() is None + + +def test_the_mail_notice_is_freed_on_the_gui_thread(qt_app, monkeypatch): + # Its last reference was the mail thread's: with the run window gone, the + # QObject was destroyed on that thread, the crash class GC had caused + import threading + import time + import weakref + + from pybreeze.extend.process_executor import process_executor_utils + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + freed_on: list = [] + sent = threading.Event() + + def send_after_test(_path, not_before, on_done): + def send() -> None: + time.sleep(0.2) + on_done(None) + sent.set() + + threading.Thread(target=send, name="mail").start() + + monkeypatch.setattr(process_executor_utils, "send_after_test", send_after_test) + window = CodeWindow() + hook = process_executor_utils.report_mail_hook(window) + notices = [cell.cell_contents for cell in hook.__closure__ + if isinstance(cell.cell_contents, process_executor_utils._MailNotice)] + notices[0].destroyed.connect(lambda *_: freed_on.append(threading.current_thread().name)) + watch = weakref.ref(notices[0]) + del notices + + hook() + del hook + window.close() + window.deleteLater() + del window + assert sent.wait(5) + deadline = time.monotonic() + 5 + while not freed_on and time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.01) + + assert freed_on == [threading.main_thread().name] + assert watch() is None or not __import__("shiboken6").isValid(watch()) diff --git a/test/test_utils/test_closed_panels_are_freed.py b/test/test_utils/test_closed_panels_are_freed.py new file mode 100644 index 00000000..5f9be74b --- /dev/null +++ b/test/test_utils/test_closed_panels_are_freed.py @@ -0,0 +1,263 @@ +"""A panel that ran a worker thread is freed once it is closed and deleted. + +A slot holding the panel, connected to a thread the panel keeps, is a cycle +through Qt that Python's collector cannot see: every such panel opened and +closed stayed in memory with its thread for the rest of the session. +""" +from __future__ import annotations + +import gc +import os +import time +import weakref + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 + + +@pytest.fixture(scope="module") +def app(): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class _NothingRunning: + """For a panel whose worker has already been waited for (and let go of).""" + + @staticmethod + def isRunning() -> bool: + return False + + +def _wait_for(app, thread, seconds: float = 10) -> None: + deadline = time.monotonic() + seconds + while thread.isRunning() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + app.processEvents() + assert not thread.isRunning() + + +def _freed_after_a_run(app, build, drive, thread_of) -> bool: + """Build a panel, run its worker to the end, close and delete it: is it gone? + + Built here so no caller holds it. + """ + from PySide6.QtCore import QCoreApplication, QEvent + + widget = build() + drive(widget) + _wait_for(app, thread_of(widget)) + watch = weakref.ref(widget) + widget.close() + widget.deleteLater() + QCoreApplication.sendPostedEvents(widget, QEvent.Type.DeferredDelete) + del widget + _forget_captured_log_records() + gc.collect() + return watch() is None + + +def _forget_captured_log_records() -> None: + """Drop the records pytest has captured so far. + + A record logged with an exception as its argument keeps the exception's + traceback, and through its frames the panel that logged it. The IDE's own + handler writes a line to a file and keeps nothing; pytest's keeps them all. + """ + import logging + + for handler in logging.getLogger().handlers: + # clear(), not reset(): pytest keeps the list reset() replaces + forget = getattr(handler, "clear", None) + if callable(forget): + forget() + + +def _cot(widget) -> None: + # A URL the panel refuses to send to: the thread ends at once, with no network + widget.url_input.setText("ftp://example.invalid/") + widget.code_paste_area.setPlainText("x") + widget.start_sending() + + +def _skills(widget) -> None: + widget.api_url_input.setText("ftp://example.invalid/") + widget.prompt_input.setPlainText("x") + widget.send_prompt() + + +def _diff(widget) -> None: + widget.left_edit.setPlainText("a") + widget.right_edit.setPlainText("b") + widget.compare() + + +def test_the_cot_review_panel(app): + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI + + assert _freed_after_a_run(app, CoTCodeReviewGUI, _cot, lambda widget: widget.thread) + + +def test_the_skill_send_panel(app): + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + + assert _freed_after_a_run(app, SkillsSendGUI, _skills, lambda widget: widget.thread) + + +def test_the_diff_tab(app): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI + + assert _freed_after_a_run(app, DiffGUI, _diff, lambda widget: widget._compare_thread) + + +def test_the_ssh_shell_after_a_failed_connect(app, monkeypatch): + import paramiko + + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget + + def refuse(*_args, **_kwargs): + raise OSError("refused") + + def connect(widget) -> None: + widget.login_widget.host_edit.setText("h") + widget.login_widget.user_edit.setText("u") + widget.connect_ssh() + + monkeypatch.setattr(paramiko.SSHClient, "connect", refuse) + + # The client's host-key policy held the panel as its dialog parent + assert _freed_after_a_run( + app, ssh_command_widget.SSHCommandWidget, connect, lambda widget: widget._connecting) + + +def test_the_diagram_editor_after_an_image_from_a_url(app, monkeypatch): + # The fetch's finished held a lambda holding the editor: the closed editor, + # its scene and every image in it stayed + from PySide6.QtCore import QBuffer, QByteArray, QIODevice + from PySide6.QtGui import QColor, QImage + from PySide6.QtWidgets import QInputDialog + + from pybreeze.pybreeze_ui.diagram_editor import diagram_scene + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + image = QImage(4, 4, QImage.Format.Format_ARGB32) + image.fill(QColor("red")) + png = QByteArray() + buffer = QBuffer(png) + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + image.save(buffer, "PNG") + monkeypatch.setattr(diagram_scene, "safe_download_image", lambda _url: bytes(png.data())) + monkeypatch.setattr( + QInputDialog, "getText", staticmethod(lambda *_args, **_kwargs: ("http://x.test/a.png", True))) + + def add_image(widget) -> None: + widget._add_image_from_url() + (fetch,) = widget._url_fetches + _wait_for(app, fetch) + deadline = time.monotonic() + 5 + while widget._url_fetches and time.monotonic() < deadline: + app.processEvents() + assert len(widget._scene.get_all_images()) == 1 + + assert _freed_after_a_run(app, DiagramEditorWidget, add_image, lambda _widget: _NothingRunning) + + +def test_the_sftp_tree_after_a_listing(app): + # The listing's finished held a lambda holding the listing: the listing + # stayed, and its other slots kept the closed tree alive + import stat + from types import SimpleNamespace + + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + + class Client: + connected = True + + @staticmethod + def list_dir(_path: str): + return [SimpleNamespace(filename="a.txt", st_mode=stat.S_IFREG, st_size=1)] + + @staticmethod + def close() -> None: + """Nothing to close.""" + + def build() -> SSHFileTreeManager: + tree = SSHFileTreeManager() + tree.client = Client() + return tree + + def list_root(tree) -> None: + tree.load_root("/") + deadline = time.monotonic() + 5 + while tree._listings and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + assert not tree._listings + + assert _freed_after_a_run(app, build, list_root, lambda _tree: _NothingRunning) + + +def test_the_regex_tab(app): + from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI + + def match(widget) -> None: + widget.pattern_edit.setText("a") + widget.text_edit.setPlainText("banana") + widget.test() + + assert _freed_after_a_run(app, RegexGUI, match, lambda widget: widget._match_thread) + + +def test_the_sftp_tree_after_an_upload(app, monkeypatch, tmp_path): + # The finished transfer stayed on the tree with its slots, which hold the tree + from PySide6.QtWidgets import QMessageBox + + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + from test_utils.test_sftp_upload import FakeSftp, _wrapper + + local_file = tmp_path / "a.txt" + local_file.write_bytes(b"x") + monkeypatch.setattr(QMessageBox, "information", lambda *_args: None) + + def build() -> SSHFileTreeManager: + tree = SSHFileTreeManager() + tree.client = _wrapper(FakeSftp()) + return tree + + def upload(tree) -> None: + assert tree._start_transfer(downloading=False, remote_path="/a.txt", local_path=str(local_file), + title="Uploaded", message="Uploaded to") + deadline = time.monotonic() + 5 + while tree._transfer is not None and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + assert tree._transfer is None + + assert _freed_after_a_run(app, build, upload, lambda _tree: _NothingRunning) + + +def _tool_keys() -> list[str]: + from pybreeze.pybreeze_ui.menu.tools import tools_menu + + return list(tools_menu._WIDGET_FACTORIES) + + +@pytest.mark.parametrize("key", _tool_keys()) +def test_every_tool_tab_opened_and_closed_is_freed(app, key): + from PySide6.QtWidgets import QTabWidget + + from pybreeze.pybreeze_ui.menu.tools import tools_menu + + class Main: + tab_widget = QTabWidget() + current_run_code_window: list = [] + + assert _freed_after_a_run( + app, lambda: tools_menu._WIDGET_FACTORIES[key](Main()), lambda _widget: None, lambda _widget: _NothingRunning) diff --git a/test/test_utils/test_closed_tool_tabs.py b/test/test_utils/test_closed_tool_tabs.py new file mode 100644 index 00000000..892ca22c --- /dev/null +++ b/test/test_utils/test_closed_tool_tabs.py @@ -0,0 +1,41 @@ +"""A closed tool tab is deleted, not kept until the IDE exits. + +JEditor's close_tab closes the page and removes the tab; ``removeTab`` keeps +the page, so every tool tab ever closed -- a diagram and its scene, an SSH +panel -- stayed in memory for the rest of the session. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_CLOSE_TABS = """ +from PySide6.QtCore import QCoreApplication, QEvent +from je_editor import EditorWidget +from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI +from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + +gone = [] +tabs = {"hash": HashGUI(window), "diagram": DiagramEditorWidget()} +for name, tab in tabs.items(): + window.tab_widget.addTab(tab, name) + tab.destroyed.connect(lambda *_, name=name: gone.append(name)) +editor = next(window.tab_widget.widget(i) for i in range(window.tab_widget.count()) + if isinstance(window.tab_widget.widget(i), EditorWidget)) +editor_gone = [] +editor.destroyed.connect(lambda *_: editor_gone.append(True)) + +for tab in tabs.values(): + window.close_tab(window.tab_widget.indexOf(tab)) + # Only this page's delete: others posted by the window's start are not ours + QCoreApplication.sendPostedEvents(tab, QEvent.Type.DeferredDelete) +app.processEvents() + +result = {"gone": sorted(gone), "editor_gone": editor_gone} +""" + + +def test_a_closed_tool_tab_is_deleted_and_an_editor_tab_is_left_to_jeditor(tmp_path): + seen = run_started_window(tmp_path, _CLOSE_TABS) + + assert seen["gone"] == ["diagram", "hash"] + assert seen["editor_gone"] == [] diff --git a/test/test_utils/test_closing_the_ide.py b/test/test_utils/test_closing_the_ide.py new file mode 100644 index 00000000..41e79570 --- /dev/null +++ b/test/test_utils/test_closing_the_ide.py @@ -0,0 +1,50 @@ +"""Closing the IDE stops the runs it started. + +A run's child is a separate process: nothing ends it when the IDE exits, and it +runs without a console, so one left behind keeps going unseen until it is found +in a task manager. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_CLOSE_WITH_A_RUN_GOING = """ +import subprocess +from pybreeze.extend.process_executor.process_executor_utils import build_task_process +# This interpreter: left unchosen, a run looks for one on PATH, which may be a +# stub that exits at once instead of a Python that sleeps. +window.python_compiler = sys.executable +build_task_process(window).start_module_process( + "timeit", ["-n", "1", "-r", "1", "import time; time.sleep(60)"]) +child = window.current_run_code_window[0].runner.process +running_before = child.poll() is None +window.close() +try: + child.wait(timeout=15) +except subprocess.TimeoutExpired: + child.kill() + stopped = False +else: + stopped = True +result = {"running_before": running_before, "stopped": stopped} +""" + + +def test_closing_the_ide_stops_a_run_still_going(tmp_path): + seen = run_started_window(tmp_path, _CLOSE_WITH_A_RUN_GOING) + + assert seen["running_before"] + assert seen["stopped"] + + +_AUTO_CLOSE_WITH_A_RUN_GOING = _CLOSE_WITH_A_RUN_GOING.replace( + "window.close()", "window.debug_close()") + + +def test_the_auto_close_of_debug_mode_stops_a_run_too(tmp_path): + # The startup tests close the IDE this way; quitting the application on its + # own skips closeEvent, and with it every bit of cleanup. + seen = run_started_window(tmp_path, _AUTO_CLOSE_WITH_A_RUN_GOING) + + assert seen["running_before"] + assert seen["stopped"] diff --git a/test/test_utils/test_code_window.py b/test/test_utils/test_code_window.py index fca16396..5c09353b 100644 --- a/test/test_utils/test_code_window.py +++ b/test/test_utils/test_code_window.py @@ -43,3 +43,381 @@ def test_old_lines_dropped_once_capped(self, qt_app): text = window.code_result.toPlainText() assert "line 499" in text # newest kept assert "line 0\n" not in text # oldest dropped + + +class TestOutputPastTheCap: + def test_a_line_written_past_the_cap_is_cheap(self, qt_app): + # In a QTextEdit each one took about 15 ms to drop the oldest line: + # 2000 of them, 30 s with the IDE frozen + import time + + from pybreeze.pybreeze_ui.show_code_window.code_window import MAX_OUTPUT_BLOCKS, CodeWindow + + window = CodeWindow() + for i in range(MAX_OUTPUT_BLOCKS): + window.append_output(f"line {i}\n") + started = time.perf_counter() + for i in range(2000): + window.append_output(f"more {i}\n", is_error=i % 2 == 0) + elapsed = time.perf_counter() - started + + assert elapsed < 5 + assert window.code_result.document().blockCount() <= MAX_OUTPUT_BLOCKS + assert window.code_result.toPlainText().endswith("more 1999\n") + + def test_the_error_colour_still_applies(self, qt_app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import actually_color_dict + from PySide6.QtGui import QTextCursor + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("fine\n") + window.append_output("broken\n", is_error=True) + cursor = QTextCursor(window.code_result.document().findBlockByNumber(1)) + cursor.movePosition(QTextCursor.MoveOperation.Right) + + assert cursor.charFormat().foreground().color() == actually_color_dict.get("error_output_color") + + +class TestAppendOutput: + def test_indentation_and_blank_lines_are_kept(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + for line in ["def f():\n", " return 1\n", "\n", "\tdone\n"]: + window.append_output(line) + + assert window.code_result.toPlainText() == "def f():\n return 1\n\n\tdone\n" + + def test_a_windows_line_ending_is_a_line_break(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("one\r\ntwo\r\n") + + assert window.code_result.toPlainText() == "one\ntwo\n" + + def test_a_lone_carriage_return_redraws_the_line(self, qt_app): + # As a terminal does: a progress bar made one line per step + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("one\n") + for step in [" 0%", "\r 50%", "\r100%", "\rdone\n"]: + window.append_output(step) + + assert window.code_result.toPlainText() == "one\ndone\n" + + @pytest.mark.parametrize("buffer_size", range(1, 16)) + def test_a_rewind_and_clear_cut_anywhere_still_rewinds(self, qt_app, buffer_size): + # A read ending on "\r\x1b[" sent the "\r" on its own, the window + # dropped it as the end of a piece, and "50%" stayed: "50%60%" + import io + from queue import Queue + + from pybreeze.extend.process_executor.queue_pump import read_stream_into_queue + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + pieces: Queue = Queue() + read_stream_into_queue(io.BytesIO(b"50%\r\x1b[K60%\n"), pieces, buffer_size=buffer_size, + encoding="utf-8", keep_reading=lambda: True) + window = CodeWindow() + while not pieces.empty(): + window.append_output(pieces.get()) + + assert window.code_result.toPlainText() == "60%\n" + + def test_a_backspace_takes_back_what_an_earlier_piece_showed(self, qt_app): + # A spinner writing each frame separately showed "|/-done" + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + for piece in ["line\n", "|", "\b/", "\b-", "\b\b\b\bdone\n"]: + window.append_output(piece) + + # Never past the start of the line: "line" stays + assert window.code_result.toPlainText() == "line\ndone\n" + + def test_a_bar_that_ends_on_a_carriage_return_stays_shown(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output(" 50%\r100%\r") + + assert window.code_result.toPlainText() == "100%" + + def test_pieces_of_one_line_join_up(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + for piece in ["abcd", "efgh", "ij\n"]: + window.append_output(piece) + + assert window.code_result.toPlainText() == "abcdefghij\n" + + def test_a_selection_is_not_overwritten(self, qt_app): + from PySide6.QtGui import QTextCursor + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("first\nsecond\n") + selection = window.code_result.textCursor() + selection.setPosition(0) + selection.setPosition(5, QTextCursor.MoveMode.KeepAnchor) + window.code_result.setTextCursor(selection) + + window.append_output("third\n") + + assert window.code_result.toPlainText() == "first\nsecond\nthird\n" + + def test_own_line_starts_after_an_unfinished_line(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("progress 90%") + window.append_output("Task exit with code 0\n", own_line=True) + window.append_output("Task exit with code 0\n", own_line=True) + + assert window.code_result.toPlainText() == ( + "progress 90%\nTask exit with code 0\nTask exit with code 0\n") + + def test_errors_use_the_error_colour(self, qt_app): + from je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file import ( + actually_color_dict, + ) + from PySide6.QtGui import QTextCursor + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("ok\n") + window.append_output("boom\n", is_error=True) + + cursor = QTextCursor(window.code_result.document()) + cursor.setPosition(1) + assert cursor.charFormat().foreground().color() == actually_color_dict["normal_output_color"] + cursor.setPosition(len("ok\n") + 1) + assert cursor.charFormat().foreground().color() == actually_color_dict["error_output_color"] + + +class TestFollowOutput: + def test_the_view_follows_output_at_the_bottom(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.show() + for i in range(300): + window.append_output(f"line {i}\n") + + scroll_bar = window.code_result.verticalScrollBar() + assert scroll_bar.maximum() > 0 + assert scroll_bar.value() == scroll_bar.maximum() + + def test_a_reader_who_scrolled_up_is_left_alone(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.show() + for i in range(300): + window.append_output(f"line {i}\n") + scroll_bar = window.code_result.verticalScrollBar() + scroll_bar.setValue(10) + + for i in range(50): + window.append_output(f"more {i}\n") + + assert scroll_bar.value() == 10 + assert scroll_bar.maximum() > 10 + + +class TestClosedWhileRunning: + """A run window closed mid-run is let go of when its run ends, not at IDE exit.""" + + class Running: + def __init__(self) -> None: + self.returncode = None + + def poll(self): + return self.returncode + + def _window(self): + from types import SimpleNamespace + + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + process = self.Running() + window.runner = SimpleNamespace(process=process) + forgotten: list = [] + window.finished_and_closed.connect(lambda: forgotten.append(window)) + return window, process, forgotten + + def test_it_is_kept_while_the_run_goes_on(self, qt_app): + window, _process, forgotten = self._window() + + window.close() + qt_app.processEvents() + + assert forgotten == [] + + def test_it_is_let_go_of_when_the_run_ends(self, qt_app): + window, process, forgotten = self._window() + window.close() + + process.returncode = 0 + window.run_ended() + assert forgotten == [] # not from inside the executor's timer slot + qt_app.processEvents() + + # It used to stay in the main window's list until the IDE exited. + assert forgotten == [window] + + def test_a_window_still_open_is_not_let_go_of(self, qt_app): + window, process, forgotten = self._window() + + process.returncode = 0 + window.run_ended() + qt_app.processEvents() + + assert forgotten == [] + + +def test_the_file_runner_says_when_the_run_ended(qt_app, tmp_path): + import sys + import time + + from PySide6.QtWidgets import QApplication + + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "quick.py" + script.write_text("print('done')\n", encoding="utf-8") + window = CodeWindow() + ended: list = [] + window.run_ended = lambda: ended.append(True) + runner = FileRunnerProcess(window) + + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + deadline = time.monotonic() + 30 + while not ended: + assert time.monotonic() < deadline, "the run never ended" + QApplication.processEvents() + time.sleep(0.01) + + assert ended == [True] + + +class TestTerminalCodes: + """A coloured or redrawn line shows as its text, not as escape codes.""" + + @pytest.mark.parametrize(("written", "shown"), [ + ("\x1b[31mred\x1b[0m done\n", "red done\n"), + ("\x1b[1;32mok\x1b[m\n", "ok\n"), + ("\x1b]0;window title\x07after\n", "after\n"), + ("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\\n", "link\n"), + ("\x1b[2K\x1b[1Gprogress 50%\n", "progress 50%\n"), + # A backspace takes the character before it back, as in a terminal + ("a\bb\x0cc\x00\tt\n", "bc\tt\n"), + # Two-byte escapes: tput sgr0's character-set reset, save/restore cursor + ("a\x1b(Bb\x1b7c\x1b8d\n", "abcd\n"), + ("\u4e2d\u6587 stays\n", "\u4e2d\u6587 stays\n"), + ]) + def test_what_the_window_shows(self, qt_app, written, shown): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output(written) + + assert window.code_result.toPlainText() == shown + + def test_a_colour_code_cut_between_two_reads_is_not_shown(self, qt_app): + # The reader hands output over in pieces; stripped one by one, + # "\x1b[3" + "2m" showed "[32m" + import io + from queue import Queue + + from pybreeze.extend.process_executor.queue_pump import read_stream_into_queue + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + written = b"".join(b"\x1b[32mPASSED test_%d\x1b[0m\n" % number for number in range(300)) + pieces: Queue = Queue() + read_stream_into_queue( + io.BufferedReader(io.BytesIO(written), buffer_size=7), pieces, + buffer_size=7, encoding="utf-8", keep_reading=lambda: True) + window = CodeWindow() + while not pieces.empty(): + window.append_output(pieces.get()) + + shown = window.code_result.toPlainText() + assert "[" not in shown + assert shown.splitlines() == [f"PASSED test_{number}" for number in range(300)] + + def test_a_coloured_progress_bar_still_redraws_its_line(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + window.append_output("10%\r\x1b[32m20%\x1b[0m\r\n") + + assert window.code_result.toPlainText() == "20%\n" + + +class TestTheStopButton: + """A run could be stopped only by closing the IDE: closing its window lets it go on.""" + + def test_is_off_until_a_run_starts_and_after_it_ends(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + assert not window.stop_button.isEnabled() + + window.run_started() + assert window.stop_button.isEnabled() + + window.run_ended() + assert not window.stop_button.isEnabled() + + def test_stops_the_runner(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + class Runner: + stopped = 0 + + def stop(self): + self.stopped += 1 + + window = CodeWindow() + window.runner = Runner() + window.run_started() + + window.stop_button.click() + + assert window.runner.stopped == 1 + + def test_stops_a_script_that_would_run_forever(self, qt_app, tmp_path): + import sys + import time + + from PySide6.QtWidgets import QApplication + + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "forever.py" + script.write_text("import time\nwhile True:\n time.sleep(0.1)\n", encoding="utf-8") + window = CodeWindow() + window.runner = FileRunnerProcess(window) + window.runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + assert window.stop_button.isEnabled() + + window.stop_button.click() + deadline = time.monotonic() + 30 + while window.stop_button.isEnabled(): + assert time.monotonic() < deadline, "the run was not stopped" + QApplication.processEvents() + time.sleep(0.01) + + assert not window.is_running() + window.close() diff --git a/test/test_utils/test_codegen_line_breaks.py b/test/test_utils/test_codegen_line_breaks.py new file mode 100644 index 00000000..6f8aae73 --- /dev/null +++ b/test/test_utils/test_codegen_line_breaks.py @@ -0,0 +1,70 @@ +"""A generated script stays the script it was, after a Qt editor has held it. + +JEditor saves and runs a tab through ``toPlainText()``, which turns U+2028, +U+2029 and U+0085 into real line breaks. JSON leaves them as they are, so text +after one in a comment became a line of code: a curl command pasted from a page +could run anything in the script it generated. +""" +from __future__ import annotations + +import ast +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QPlainTextEdit + +from pybreeze.utils.curl_import.curl_parser import parse_curl +from pybreeze.utils.curl_import.request_codegen import python_string +from pybreeze.utils.curl_import.script_templates import generate_template +from pybreeze.utils.har_import.har_codegen import generate_har_script + +_BREAKS = ["\u2028", "\u2029", "\x85"] + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def _through_an_editor(code: str) -> str: + """*code* as JEditor saves or runs it: set in a text edit, read back with toPlainText().""" + editor = QPlainTextEdit() + editor.setPlainText(code) + return editor.toPlainText() + + +def _raises(code: str) -> bool: + return any(isinstance(node, ast.Raise) for node in ast.walk(ast.parse(code))) + + +@pytest.mark.parametrize("line_break", _BREAKS) +def test_a_string_keeps_the_break_as_an_escape(line_break): + written = python_string(f"a{line_break}b") + + assert line_break not in written + assert ast.literal_eval(written) == f"a{line_break}b" + + +@pytest.mark.parametrize("line_break", _BREAKS) +def test_a_cookie_file_name_cannot_add_code(app, line_break): + request = parse_curl(f'curl https://x.example -b "s{line_break}raise SystemExit(1){line_break}#"') + + code = _through_an_editor(generate_template("requests", request)) + + assert not _raises(code) + + +@pytest.mark.parametrize("line_break", _BREAKS) +def test_a_har_url_cannot_add_code(app, line_break): + from pybreeze.utils.har_import.har_parser import parse_har + + har = ('{"log": {"entries": [{"request": {"method": "GET", "url": "https://x.example/a' + + line_break.encode("unicode_escape").decode().replace("\\x85", "\\u0085") + + 'raise SystemExit(1)", "headers": []}, "response": {"status": 200}}]}}') + requests = [entry.request for entry in parse_har(har)] + + code = _through_an_editor(generate_har_script("requests", requests)) + + assert not _raises(code) diff --git a/test/test_utils/test_compile_then_run.py b/test/test_utils/test_compile_then_run.py new file mode 100644 index 00000000..3bd4e19f --- /dev/null +++ b/test/test_utils/test_compile_then_run.py @@ -0,0 +1,140 @@ +"""A plugin run that compiles first: the compiler is a streamed child, not a UI-thread wait.""" +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +from pybreeze.extend.process_executor import file_runner_process +from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + +_WAIT_SECONDS = 30 + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +def _run_events_until(qt_app, condition) -> None: + deadline = time.monotonic() + _WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + qt_app.processEvents() + time.sleep(0.01) + + +def _compiler(tmp_path, body: str) -> dict: + """A run config whose "compiler" is Python running *body* as a script.""" + script = tmp_path / "compiler.py" + script.write_text("import sys, time, pathlib\n" + body, encoding="utf-8") + return {"name": "Fake C", "compiler": sys.executable, "args": (str(script),), + "compile_then_run": True, "output_flag": "-o"} + + +def _runner(monkeypatch): + """A runner whose compile runs for real and whose run step is only recorded.""" + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + runner = FileRunnerProcess(window) + runs: list = [] + real_start = runner._start_process + + def start(command, cleanup_dir=None, after_exit=None, time_limit=None): + if after_exit is None: # the run of the compiled binary + runs.append((command, cleanup_dir)) + return + real_start(command, cleanup_dir, after_exit, time_limit) + + monkeypatch.setattr(runner, "_start_process", start) + return window, runner, runs + + +class TestCompileThenRun: + def test_the_compile_does_not_hold_the_ui_and_the_run_follows(self, qt_app, tmp_path, monkeypatch): + go = tmp_path / "go" + config = _compiler(tmp_path, ( + f"flag = pathlib.Path({str(go)!r})\n" + "print('compiling', flush=True)\n" + "deadline = time.monotonic() + 20\n" + "while not flag.exists() and time.monotonic() < deadline:\n" + " time.sleep(0.02)\n" + )) + window, runner, runs = _runner(monkeypatch) + source = tmp_path / "main.c" + + runner.run_file(config, str(source)) + assert runner.process is not None # back while the compiler still works + _run_events_until(qt_app, lambda: "compiling" in window.code_result.toPlainText()) + assert runs == [] # its output streamed in before it finished + go.touch() + _run_events_until(qt_app, lambda: runs) + + # Built in a folder of its own, which goes once it has run: beside the + # source it replaced a file of that name there + [(command, build_dir)] = runs + built = Path(command[0]) + assert built.name == "main" + (".exe" if sys.platform == "win32" else "") + assert built.parent == Path(build_dir) and built.parent != tmp_path + assert f"[Run] {built}" in window.code_result.toPlainText() + + def test_a_failed_compile_is_reported_and_nothing_runs(self, qt_app, tmp_path, monkeypatch): + config = _compiler(tmp_path, "print('main.c:1: error', file=sys.stderr)\nsys.exit(3)\n") + window, runner, runs = _runner(monkeypatch) + + runner.run_file(config, str(tmp_path / "main.c")) + _run_events_until(qt_app, lambda: runner.process is None) + + text = window.code_result.toPlainText() + assert "main.c:1: error" in text + assert text.endswith("[Compile failed] exit code 3\n") + assert "[Process exited" not in text + assert runs == [] + + def test_a_compile_that_runs_too_long_is_stopped(self, qt_app, tmp_path, monkeypatch): + monkeypatch.setattr(file_runner_process, "COMPILE_TIME_LIMIT_SECONDS", 0.5) + config = _compiler(tmp_path, "time.sleep(30)\n") + window, runner, runs = _runner(monkeypatch) + + runner.run_file(config, str(tmp_path / "main.c")) + _run_events_until(qt_app, lambda: runner.process is None) + + text = window.code_result.toPlainText() + assert "Timed out after 0.5s" in text + assert "[Compile failed]" in text + assert runs == [] + + def test_a_missing_compiler_is_reported(self, qt_app, tmp_path, monkeypatch): + window, runner, runs = _runner(monkeypatch) + config = {"name": "Fake C", "compiler": str(tmp_path / "no-such-compiler"), + "compile_then_run": True} + + runner.run_file(config, str(tmp_path / "main.c")) + + assert "[Error] Command not found" in window.code_result.toPlainText() + assert runner.process is None and runs == [] + + +class TestRunArguments: + """JEditor does not check a plugin's run config: ``"args": "run"`` ran ``go r u n main.go``.""" + + @pytest.mark.parametrize(("config", "expected"), [ + ({"args": ("run",)}, ["run"]), + ({"args": ["build", "-v"]}, ["build", "-v"]), + ({"args": "run"}, ["run"]), + ({"args": ""}, []), + ({}, []), + ({"args": None}, []), + ({"args": 3}, []), + ({"args": (1, "x")}, ["1", "x"]), + ]) + def test_what_goes_between_the_compiler_and_the_file(self, config, expected): + assert file_runner_process.run_arguments(config) == expected diff --git a/test/test_utils/test_cot_session_reuse.py b/test/test_utils/test_cot_session_reuse.py index b331bf32..67584b65 100644 --- a/test/test_utils/test_cot_session_reuse.py +++ b/test/test_utils/test_cot_session_reuse.py @@ -6,8 +6,9 @@ class _FakeResponse: - def __init__(self, body: bytes): + def __init__(self, body: bytes, status_code: int = 200): self._body = body + self.status_code = status_code self.encoding = "utf-8" self.closed = False @@ -100,7 +101,7 @@ def test_a_failed_step_is_shown_but_never_quoted_back(): thread._run_templates(session, "print('x')") # The failure still reaches the user ... - assert "endpoint down" in received["linter.md"] + assert "RequestException" in received["linter.md"] # what failed, never the URL # ... every later step still runs ... assert len(session.post_calls) == len(COT_TEMPLATE_FILES) # ... and none of them carries the failure text into the model. @@ -153,7 +154,7 @@ def __init__(self): super().__init__() created["session"] = self - monkeypatch.setattr(mod.requests, "Session", _TrackedSession) + monkeypatch.setattr(mod, "public_session", _TrackedSession) # Make the work raise to prove the finally still closes the session. monkeypatch.setattr(SenderThread, "_run_templates", lambda self, session, code: (_ for _ in ()).throw(RuntimeError("boom"))) @@ -164,3 +165,131 @@ def __init__(self): except RuntimeError: pass assert created["session"].closed is True + + +def test_closing_mid_review_does_not_wait(monkeypatch): + import threading + import time + + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI + from pybreeze.pybreeze_ui.thread_keeper import is_kept + + app = _qt_app() + answering = threading.Event() + monkeypatch.setattr(code_review_thread.SenderThread, "run", lambda self: answering.wait(5)) + gui = CoTCodeReviewGUI() + gui.thread = code_review_thread.SenderThread(files=[], code="", url="https://review.example") + gui.thread.start() + thread = gui.thread + + gui.close() # returns while the request is still out + + assert is_kept(thread) + assert thread.isInterruptionRequested() + answering.set() + deadline = time.monotonic() + 5 + while is_kept(thread): + assert time.monotonic() < deadline, "the review thread was never let go" + app.processEvents() + time.sleep(0.01) + + +class _ErrorPageSession(_FakeSession): + """Answers every step but the *fail_on*-th, which gets an HTTP 500 error page.""" + + def __init__(self, fail_on: int): + super().__init__() + self.fail_on = fail_on + + def post(self, url, **kwargs): + self.post_calls.append((url, kwargs)) + if len(self.post_calls) == self.fail_on: + return _FakeResponse(b"

Internal Server Error

", status_code=500) + return _FakeResponse(f"answer {len(self.post_calls)}".encode()) + + +def test_an_error_status_is_a_failed_step_not_an_answer(): + # An HTTP error page used to count as the step's answer and was quoted into + # the steps after it as if it were the model's findings. + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import COT_TEMPLATE_FILES + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + linter_step = COT_TEMPLATE_FILES.index("linter.md") + thread = SenderThread(files=list(COT_TEMPLATE_FILES), code="print('x')", + url="https://example.com/api") + received = {} + thread.update_response.connect(lambda name, resp: received.__setitem__(name, resp)) + + session = _ErrorPageSession(fail_on=linter_step + 1) + thread._run_templates(session, "print('x')") + + assert "HTTP 500" in received["linter.md"] + later = _prompts(session)[linter_step + 1:] + assert not any("Internal Server Error" in prompt for prompt in later) + + +def test_a_failed_step_does_not_log_the_url(monkeypatch): + # A requests error names the whole URL, and an API URL may carry a token. + _qt_app() + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + logged: list = [] + monkeypatch.setattr( + code_review_thread.pybreeze_logger, "error", + lambda message, *args: logged.append(message % args)) + + class _Refusing(_FakeSession): + def post(self, url, **kwargs): + import requests + + raise requests.ConnectionError(f"Max retries exceeded with url: {url}") + + thread = SenderThread(files=["linter.md"], code="print('x')", + url="https://example.com/api?token=not-a-real-token") + thread._run_templates(_Refusing(), "print('x')") + + assert logged and all("not-a-real-token" not in line for line in logged) + + +def _sending_gui(monkeypatch): + """A CoT panel whose worker is built but never started.""" + from pybreeze.pybreeze_ui.extend_ai_gui.code_review import code_review_thread + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI + + _qt_app() + monkeypatch.setattr(code_review_thread.SenderThread, "start", lambda self: None) + gui = CoTCodeReviewGUI() + gui.url_input.setText("https://review.example/api") + return gui + + +def test_sending_resolves_nothing_on_the_ui_thread(monkeypatch): + # The panel checked the URL itself before the worker did, and that check's + # DNS lookup froze the IDE for as long as the resolver took. + import socket + + looked_up: list = [] + monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: looked_up.append(a) or []) + gui = _sending_gui(monkeypatch) + + gui.start_sending() + + assert looked_up == [] + assert gui.thread is not None + gui.deleteLater() + + +def test_a_new_run_clears_the_last_runs_answers(monkeypatch): + gui = _sending_gui(monkeypatch) + gui.handle_response("error", "Cannot resolve hostname") + gui.handle_response("linter.md", "about the previous code") + + gui.start_sending() + + assert gui.responses == {} + assert gui.response_selector.count() == 0 + assert gui.response_view.toPlainText() == "" + gui.deleteLater() diff --git a/test/test_utils/test_create_project.py b/test/test_utils/test_create_project.py new file mode 100644 index 00000000..d2b88b55 --- /dev/null +++ b/test/test_utils/test_create_project.py @@ -0,0 +1,134 @@ +"""Create Project: into the folder open in the IDE, asking before it writes over a template. + +The packages rewrite every template file whenever they run. The entry called +them with no path, so the files went to the process's working directory; a +second click wiped the user's edits without a word, and a failure to write +raised out of the menu slot. +""" +from __future__ import annotations + +import os +import sys +import types + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.automation_menu_factory import safe_create_project + +_PACKAGE = "fake_automation_package" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def package(monkeypatch): + """A package whose create_project_dir writes one template file, as the real ones do.""" + fake = types.ModuleType(_PACKAGE) + fake.fail_with = None + + def create_project_dir(project_path: str | None = None, parent_name: str = "FakeProject") -> None: + if fake.fail_with is not None: + raise fake.fail_with + folder = os.path.join(project_path or os.getcwd(), parent_name, "keyword") + os.makedirs(folder, exist_ok=True) + with open(os.path.join(folder, "keyword1.json"), "w", encoding="utf-8") as file: + file.write("template") + + fake.create_project_dir = create_project_dir + monkeypatch.setitem(sys.modules, _PACKAGE, fake) + return fake + + +@pytest.fixture() +def dialogs(monkeypatch): + """Record every message box; ``answer`` is what a question gets.""" + shown: dict = {"question": [], "warning": [], "information": [], "answer": QMessageBox.StandardButton.No} + + def question(_parent, _title, text, *_args): + shown["question"].append(text) + return shown["answer"] + + monkeypatch.setattr(QMessageBox, "question", staticmethod(question)) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda _p, _t, text: shown["warning"].append(text))) + monkeypatch.setattr( + QMessageBox, "information", staticmethod(lambda _p, _t, text: shown["information"].append(text))) + return shown + + +def _window(working_dir) -> QWidget: + window = QWidget() + window.working_dir = str(working_dir) + return window + + +def test_the_project_goes_into_the_folder_open_in_the_ide(package, dialogs, tmp_path, monkeypatch): + elsewhere = tmp_path / "process_cwd" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + opened = tmp_path / "opened" + opened.mkdir() + + safe_create_project(_window(opened), _PACKAGE)() + + assert (opened / "FakeProject" / "keyword" / "keyword1.json").is_file() + assert not (elsewhere / "FakeProject").exists() + assert dialogs["question"] == [] + assert str(opened / "FakeProject") in dialogs["information"][0] + + +def test_an_edited_template_is_kept_unless_the_user_agrees(package, dialogs, tmp_path): + edited = tmp_path / "FakeProject" / "keyword" / "keyword1.json" + edited.parent.mkdir(parents=True) + edited.write_text("my edits", encoding="utf-8") + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert edited.read_text(encoding="utf-8") == "my edits" + assert len(dialogs["question"]) == 1 + assert dialogs["information"] == [] + + +def test_the_user_can_agree_to_replace_it(package, dialogs, tmp_path): + edited = tmp_path / "FakeProject" / "keyword" / "keyword1.json" + edited.parent.mkdir(parents=True) + edited.write_text("my edits", encoding="utf-8") + dialogs["answer"] = QMessageBox.StandardButton.Yes + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert edited.read_text(encoding="utf-8") == "template" + + +def test_a_write_that_fails_is_reported_not_raised(package, dialogs, tmp_path): + package.fail_with = PermissionError(13, "Access is denied") + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert "Access is denied" in dialogs["warning"][0] + assert dialogs["information"] == [] + + +def test_a_package_that_is_not_installed_is_reported(dialogs, tmp_path, monkeypatch): + monkeypatch.setitem(sys.modules, _PACKAGE, None) # makes the import fail + + safe_create_project(_window(tmp_path), _PACKAGE)() + + assert _PACKAGE in dialogs["warning"][0] + + +def test_without_a_folder_open_it_uses_the_working_directory(package, dialogs, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + window = QWidget() + + safe_create_project(window, _PACKAGE)() + + assert (tmp_path / "FakeProject" / "keyword" / "keyword1.json").is_file() diff --git a/test/test_utils/test_curl_import.py b/test/test_utils/test_curl_import.py index f8f176a1..681c61e6 100644 --- a/test/test_utils/test_curl_import.py +++ b/test/test_utils/test_curl_import.py @@ -6,7 +6,6 @@ from pybreeze.utils.curl_import.curl_parser import ( CurlRequest, parse_curl, - parse_query_pairs, ) from pybreeze.utils.curl_import.request_codegen import to_requests_code from pybreeze.utils.exception.exceptions import CurlParseException @@ -139,6 +138,16 @@ def test_get_flag_moves_data_to_params(self): class TestParseCurlRobustness: + @pytest.mark.parametrize("url", ["http://[::1/api", "http://host:port/x"]) + def test_a_malformed_url_is_a_parse_error(self, url): + # It raised ValueError later, from the code generator, out of the tab, + # which kept showing the previous command's code. + with pytest.raises(CurlParseException): + parse_curl(f"curl '{url}'") + + def test_an_ipv6_url_with_a_port_is_fine(self): + assert parse_curl("curl 'http://[::1]:8080/a'").url == "http://[::1]:8080/a" + def test_line_continuations(self): command = "curl https://x \\\n -H 'Accept: application/json' \\\n -d 'a=1'" request = parse_curl(command) @@ -162,6 +171,12 @@ def test_non_curl_command_raises(self): with pytest.raises(CurlParseException): parse_curl("wget https://x") + @pytest.mark.parametrize("command", ["curl", "curl -X POST -d a=1", "curl -H 'Accept: x'", "curl '#top'"]) + def test_a_command_without_a_url_raises(self, command): + # curl says "no URL specified"; a script calling requests.get("") only fails when run + with pytest.raises(CurlParseException, match="no URL"): + parse_curl(command) + def test_unbalanced_quotes_raise(self): with pytest.raises(CurlParseException): parse_curl("curl 'https://x") @@ -299,8 +314,19 @@ def test_query_moved_to_params(self): assert request.params == {"a": "1", "b": "2"} def test_query_values_are_url_decoded(self): - request = parse_curl("curl 'https://x?q=hello%20world'") - assert request.params["q"] == "hello world" + request = parse_curl("curl 'https://x?q=hello+world&tag=a%2Bb'") + assert request.params == {"q": "hello world", "tag": "a+b"} + + @pytest.mark.parametrize("url", [ + "https://x/a?q=hello%20world", "https://x/a?flag&q=1", "https://x/a?q=%B0", + "https://x/a?r=/x&s=a,b", "https://x/a?X-Amz-Signature=ab%2Fcd&X-Amz-Date=20260923T000000Z%20", + ]) + def test_a_query_that_would_not_come_back_as_written_stays_in_the_url(self, url): + # Split and encoded again it went out changed: %20 as +, flag as flag=, + # %B0 as %EF%BF%BD, / as %2F -- which breaks a signed URL + request = parse_curl(f"curl '{url}'") + assert request.params == {} + assert request.full_url == url def test_blank_query_value_kept(self): request = parse_curl("curl 'https://x?flag='") @@ -323,9 +349,29 @@ def test_full_url_includes_get_flag_params(self): request = parse_curl("curl -G https://x/api -d 'a=1'") assert request.full_url == "https://x/api?a=1" - def test_explicit_params_not_overwritten_by_url_query(self): + def test_url_query_and_get_data_are_both_sent_url_first(self): + # What curl 8 sends for this command: ?a=fromurl&a=fromdata request = parse_curl("curl -G 'https://x?a=fromurl' -d 'a=fromdata'") - assert request.params["a"] == "fromdata" + assert request.params["a"] == ["fromurl", "fromdata"] + assert request.full_url == "https://x?a=fromurl&a=fromdata" + + def test_a_repeated_url_key_keeps_every_value(self): + request = parse_curl("curl 'https://x/p?id=1&id=2&q=a'") + assert request.params == {"id": ["1", "2"], "q": "a"} + assert request.full_url == "https://x/p?id=1&id=2&q=a" + + def test_a_repeated_get_data_key_keeps_every_value(self): + request = parse_curl("curl -G https://x/p -d id=1 -d id=2") + assert request.full_url == "https://x/p?id=1&id=2" + + def test_generated_code_sends_every_value(self): + import ast + + code = to_requests_code(parse_curl("curl 'https://x/p?id=1&id=2'")) + assignment = next( + node for node in ast.parse(code).body + if isinstance(node, ast.Assign) and node.targets[0].id == "params") + assert ast.literal_eval(assignment.value) == {"id": ["1", "2"]} def test_url_query_feeds_requests_params(self): code = to_requests_code(parse_curl("curl 'https://x/api?a=1'")) @@ -348,11 +394,13 @@ def test_long_cookie_flag(self): request = parse_curl("curl --cookie 'a=1' https://x") assert request.cookies == {"a": "1"} - def test_cookie_file_falls_back_to_header(self): - # A bare token with no '=' is a cookie file curl would read, not pairs. + def test_a_cookie_file_is_kept_as_a_file_not_sent_as_a_cookie(self): + # A bare token with no '=' is a cookie file curl reads. It was sent as + # the header "Cookie: cookies.txt". request = parse_curl("curl -b cookies.txt https://x") assert request.cookies == {} - assert request.headers["Cookie"] == "cookies.txt" + assert "Cookie" not in request.headers + assert request.cookie_files == ["cookies.txt"] def test_no_cookies_by_default(self): assert parse_curl("curl https://x").cookies == {} @@ -421,6 +469,23 @@ def test_inline_data_still_works(self): assert request.data_file_refs == [] assert request.body == "a=1" + @pytest.mark.parametrize(("command", "sent"), [ + ("curl -d @a.txt -d b=1 https://x", b"A&b=1"), + ("curl -d b=1 -d @a.txt -d c=2 --data-binary @z.bin https://x", b"b=1&A&c=2&Z\r\n"), + ("curl -d x=1 -d y=2 -d @a.txt https://x", b"x=1&y=2&A"), + ]) + def test_the_pieces_are_sent_in_command_line_order(self, tmp_path, monkeypatch, command, sent): + # Every inline piece went first: -d @a.txt -d b=1 sent "b=1&A" + from pybreeze.utils.curl_import.request_codegen import data_from_file_expr + + (tmp_path / "a.txt").write_bytes(b"A\r\n") + (tmp_path / "z.bin").write_bytes(b"Z\r\n") + monkeypatch.chdir(tmp_path) + + expression = data_from_file_expr(parse_curl(command)) + + assert eval(expression, {"__builtins__": {"open": open}}) == sent # noqa: S307 — the generator's own output, in a test + class TestParseCurlDataUrlencode: def test_encodes_value_part(self): @@ -473,18 +538,19 @@ def test_multiple_form_fields(self): def test_form_string_flag(self): request = parse_curl("curl --form-string 'a=1' https://x") - assert request.form_fields == ["a=1"] + assert request.form_strings == ["a=1"] + assert request.has_form and request.has_body + def test_form_string_takes_an_at_sign_literally(self): + from pybreeze.utils.curl_import.request_body import form_parts -class TestParseQueryPairs: - def test_parses_pairs(self): - assert parse_query_pairs(["a=1", "b=2"]) == {"a": "1", "b": "2"} + request = parse_curl("curl --form-string 'handle=@alice' -F 'photo=@me.png' https://x") - def test_ignores_fragments_without_equals(self): - assert parse_query_pairs(["a=1", "bad"]) == {"a": "1"} + assert form_parts(request) == ({"handle": "@alice"}, {"photo": "me.png"}) - def test_empty(self): - assert parse_query_pairs([]) == {} + @pytest.mark.parametrize("value", ["nan", "inf", "-inf"]) + def test_a_timeout_that_is_not_finite_is_ignored(self, value): + assert parse_curl(f"curl -m {value} https://x").timeout is None class TestToRequestsCode: @@ -560,3 +626,55 @@ def test_defaults(self): assert request.headers == {} # The JSON round-trip in codegen should never see a stale shared dict. assert CurlRequest().headers is not request.headers + + +class TestGetWithData: + """With -G, curl appends each -d fragment to the URL as given, joined by '&'.""" + + def test_one_fragment_with_several_pairs_is_split(self): + request = parse_curl("curl -G https://x/api -d 'a=1&b=2'") + + assert request.params == {"a": "1", "b": "2"} + assert request.full_url == "https://x/api?a=1&b=2" + + def test_data_urlencode_is_encoded_once(self): + request = parse_curl("curl -G https://x/api --data-urlencode 'q=hello world'") + + assert request.params == {"q": "hello world"} + assert request.full_url == "https://x/api?q=hello+world" + + +class TestBashAnsiCQuoting: + """Copy as cURL (bash) writes a body holding a newline or a quote as $'...'.""" + + def test_a_json_body_with_a_newline_is_json(self): + from pybreeze.utils.curl_import.request_body import body_kind + + # The JSON escape \n reaches bash as \\n inside $'...' + request = parse_curl( + "curl https://x/api -H 'content-type: application/json' " + "--data-raw $'{\"msg\":\"a\\\\nb\"}'") + + assert request.body == '{"msg":"a\\nb"}' + assert body_kind(request) == ("json", {"msg": "a\nb"}) + + def test_an_escaped_quote_is_a_quote(self): + assert parse_curl("curl https://x --data-raw $'it\\'s'").body == "it's" + + @pytest.mark.parametrize(("escape", "character"), [ + ("\\t", "\t"), ("\\\\", "\\"), ("\\x41", "A"), ("\\u00e9", "\u00e9"), + ("\\U0001F600", "\U0001F600"), ("\\101", "A"), ("\\e", "\x1b"), ("\\q", "\\q"), + ]) + def test_each_escape_stands_for_its_character(self, escape, character): + assert parse_curl(f"curl https://x -d $'[{escape}]'").body == f"[{character}]" + + def test_a_dollar_quote_inside_other_quotes_is_literal(self): + assert parse_curl("curl https://x -d \"a $'b'\"").body == "a $'b'" + assert parse_curl("curl https://x -d 'a $b'").body == "a $b" + + def test_an_unterminated_one_is_refused(self): + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + parse_curl("curl https://x -d $'open") + diff --git a/test/test_utils/test_curl_import_gui.py b/test/test_utils/test_curl_import_gui.py index a16a7797..94638470 100644 --- a/test/test_utils/test_curl_import_gui.py +++ b/test/test_utils/test_curl_import_gui.py @@ -141,7 +141,7 @@ def test_open_in_editor_adds_tab_with_code(self, widget_with_window): gui.input_edit.setPlainText("curl https://example.com/api") gui.convert() with patch( - "je_editor.pyside_ui.main_ui.editor.editor_widget.EditorWidget", _FakeEditor + "je_editor.EditorWidget", _FakeEditor ): editor = gui.actions.open_in_editor() assert len(window.tab_widget.added) == 1 @@ -273,6 +273,26 @@ def test_open_headers_opens_prefilled_analyzer(self, widget_with_window): assert "Accept: application/json" in output assert "X-Trace: 1" in output + def test_cookies_from_b_go_as_the_cookie_header_they_are_sent_as(self, widget_with_window): + # They were left out: the analyzer never saw the Cookie header curl sends + gui, window = widget_with_window + gui.input_edit.setPlainText("curl https://x -b 'c=3; d=4'") + gui.convert() + + assert gui.open_headers_button.isEnabled() + gui.open_headers_in_analyzer() + assert "Cookie: c=3; d=4" in window.tab_widget.added[0][0].output_edit.toPlainText() + + def test_a_cookie_header_given_is_what_is_sent_and_shown(self, widget_with_window): + # curl (and requests) sends -b's cookies only when no Cookie header is given + gui, window = widget_with_window + gui.input_edit.setPlainText("curl https://x -H 'Cookie: a=1' -b 'c=3'") + gui.convert() + gui.open_headers_in_analyzer() + + output = window.tab_widget.added[0][0].output_edit.toPlainText() + assert "Cookie: a=1" in output and "c=3" not in output + def test_open_headers_before_convert_is_noop(self, widget_with_window): gui, window = widget_with_window assert gui.open_headers_in_analyzer() is None diff --git a/test/test_utils/test_curl_request_fidelity.py b/test/test_utils/test_curl_request_fidelity.py new file mode 100644 index 00000000..f4f94ed1 --- /dev/null +++ b/test/test_utils/test_curl_request_fidelity.py @@ -0,0 +1,202 @@ +"""The request a curl command imports as is the request curl would send.""" +from __future__ import annotations + +import pytest + +from pybreeze.utils.curl_import.curl_parser import parse_curl +from pybreeze.utils.curl_import.request_body import form_parts +from pybreeze.utils.curl_import.request_codegen import to_requests_code +from pybreeze.utils.curl_import.script_templates import apitestka_call_block +from pybreeze.utils.har_import.har_parser import parse_har + + +class TestHead: + @pytest.mark.parametrize("command", [ + "curl -I https://x", "curl --head https://x", "curl -sI https://x", + ]) + def test_it_is_a_head_request(self, command): + # -I was skipped as a display flag, and the code sent a GET. + assert parse_curl(command).method == "HEAD" + + def test_an_explicit_method_wins(self): + assert parse_curl("curl -I -X OPTIONS https://x").method == "OPTIONS" + + +class TestOauth2Bearer: + def test_it_becomes_the_authorization_header(self): + # The token was consumed and thrown away. + assert parse_curl("curl --oauth2-bearer T0K https://x").headers == { + "Authorization": "Bearer T0K"} + + @pytest.mark.parametrize("command", [ + "curl --oauth2-bearer T0K -H 'Authorization: Basic eg==' https://x", + "curl -H 'Authorization: Basic eg==' --oauth2-bearer T0K https://x", + ]) + def test_an_explicit_authorization_header_wins(self, command): + assert parse_curl(command).headers == {"Authorization": "Basic eg=="} + + +class TestFragment: + def test_it_is_not_sent(self): + request = parse_curl("curl 'https://x/a?b=1#frag'") + + # It became part of the last query value: b="1#frag". + assert request.params == {"b": "1"} + assert request.full_url == "https://x/a?b=1" + + def test_a_fragment_without_a_query_is_dropped_too(self): + assert parse_curl("curl 'https://x/a#top'").url == "https://x/a" + + +class TestRepeatedFormFields: + COMMAND = "curl -F f=@a.txt -F f=@b.txt -F k=1 -F k=2 https://x/up" + + def test_every_value_is_kept(self): + # A dict kept only the last: one file uploaded out of two. + assert form_parts(parse_curl(self.COMMAND)) == ( + {"k": ["1", "2"]}, {"f": ["a.txt", "b.txt"]}) + + def test_the_requests_code_uploads_both_files(self): + code = to_requests_code(parse_curl(self.COMMAND)) + + assert '("f", open("a.txt", "rb")),' in code + assert '("f", open("b.txt", "rb")),' in code + + def test_the_apitestka_call_uploads_both_files(self): + block = apitestka_call_block(parse_curl(self.COMMAND)) + + assert '("f", open("a.txt", "rb")), ("f", open("b.txt", "rb"))' in block + assert '("k", (None, "1")), ("k", (None, "2"))' in block + + def test_a_field_given_once_is_written_as_before(self): + assert 'files={"f": open("a.txt", "rb")},' in apitestka_call_block( + parse_curl("curl -F f=@a.txt https://x/up")) + + def test_a_recorded_multipart_upload_keeps_both_files(self): + import json + + har = json.dumps({"log": {"entries": [{"request": { + "method": "POST", "url": "https://x/up", + "postData": {"mimeType": "multipart/form-data; boundary=z", "params": [ + {"name": "f", "fileName": "a.txt"}, {"name": "f", "fileName": "b.txt"}]}, + }}]}}) + + (entry,) = parse_har(har) + + assert form_parts(entry.request)[1] == {"f": ["a.txt", "b.txt"]} + + +def _what_requests_sends(code: str): + """Run generated ``requests`` code with the network cut out, and prepare what it would send.""" + import requests + + sent: list = [] + + def capture(method, url, **kwargs): + sent.append(requests.Request(method, url, **kwargs).prepare()) + return type("Response", (), {"status_code": 200, "text": ""})() + + namespace = {"requests": type("R", (), {"request": staticmethod(capture)})} + exec(compile(code.replace("import requests\n", ""), "", "exec"), namespace) # noqa: S102 — our own generated code, run against a stand-in + return sent[0] + + +class TestAFormIsSentAsMultipart: + """curl sends every -F form as multipart/form-data, text-only or not.""" + + def test_a_text_only_form(self): + # It was data=: requests sent it URL-encoded, name=x¬e=a+b + prepared = _what_requests_sends(to_requests_code(parse_curl("curl -F name=x -F 'note=a b' https://h/api"))) + + assert prepared.headers["Content-Type"].startswith("multipart/form-data; boundary=") + assert b'name="note"\r\n\r\na b\r\n' in prepared.body + + def test_a_copied_multipart_header_does_not_hide_the_boundary(self): + # Kept, it went out with no boundary, and requests does not replace a header it is given + command = "curl -H 'Content-Type: multipart/form-data' -H 'X-Kept: 1' -F name=x https://h/api" + prepared = _what_requests_sends(to_requests_code(parse_curl(command))) + + assert prepared.headers["Content-Type"].startswith("multipart/form-data; boundary=") + assert prepared.headers["X-Kept"] == "1" + + def test_a_json_body_keeps_its_content_type(self): + command = "curl -H 'Content-Type: application/json' -d '{\"a\": 1}' https://h/api" + prepared = _what_requests_sends(to_requests_code(parse_curl(command))) + + assert prepared.headers["Content-Type"] == "application/json" + + def test_a_recorded_multipart_form_leaves_the_browsers_boundary_out(self): + import json + + har = json.dumps({"log": {"entries": [{"request": { + "method": "POST", "url": "https://h/api", + "headers": [{"name": "Content-Type", "value": "multipart/form-data; boundary=----WebKitFormBoundaryX"}], + "postData": {"mimeType": "multipart/form-data; boundary=----WebKitFormBoundaryX", + "params": [{"name": "title", "value": "hi"}]}, + }}]}}) + (entry,) = parse_har(har) + + prepared = _what_requests_sends(to_requests_code(entry.request)) + + assert "WebKitFormBoundaryX" not in prepared.headers["Content-Type"] + assert prepared.headers["Content-Type"].split("boundary=")[1].encode() in prepared.body + + +class TestABodyReadFromAFile: + """curl sends a file's bytes: --data-binary as they are, -d without CR and LF.""" + + def _sent_body(self, command: str, folder, monkeypatch) -> bytes: + monkeypatch.chdir(folder) + return _what_requests_sends(to_requests_code(parse_curl(command))).body + + def test_a_binary_file_is_sent_byte_for_byte(self, tmp_path, monkeypatch): + # It was read as UTF-8 text, and the script raised UnicodeDecodeError + (tmp_path / "photo.jpg").write_bytes(b"\xff\xfe\x00\r\n1") + + assert self._sent_body("curl --data-binary @photo.jpg https://h/up", tmp_path, monkeypatch) == ( + b"\xff\xfe\x00\r\n1") + + def test_a_data_file_loses_its_line_breaks_as_curl_sends_it(self, tmp_path, monkeypatch): + (tmp_path / "body.txt").write_bytes(b"a=1\r\nb=2\n") + + assert self._sent_body("curl -d x=0 -d @body.txt https://h/up", tmp_path, monkeypatch) == b"x=0&a=1b=2" + + def test_get_with_a_body_file_is_refused(self): + # -G makes the file's content the query, which the script cannot know + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + parse_curl("curl -G -d @query.txt https://h/find") + + +class TestACookieFile: + def test_the_script_says_it_does_not_read_it(self): + code = to_requests_code(parse_curl("curl -b cookies.txt https://h/")) + + assert '# curl read cookies from "cookies.txt" (-b)' in code + assert "Cookie" not in code.split("# curl read cookies")[1].split("\n", 1)[1] + + def test_the_json_action_refuses_it(self): + from pybreeze.utils.curl_import.script_templates import to_apitestka_action_json + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + to_apitestka_action_json(parse_curl("curl -b cookies.txt https://h/")) + + +@pytest.mark.parametrize("url", [ + "https://h/a?flag&q=%B0&r=/x&s=a,b", + "https://h/a?q=hello%20world", + "https://h/a?X-Amz-Credential=AK%2F20260923%2Fus-east-1&X-Amz-Signature=abc", +]) +def test_the_query_goes_out_as_it_was_pasted(url): + # Decoded and encoded again, it changed: flag=, %EF%BF%BD, %2Fx, a%2Cb, + + prepared = _what_requests_sends(to_requests_code(parse_curl(f"curl '{url}'"))) + + assert prepared.url == url + + +def test_get_data_is_added_after_a_query_kept_as_written(): + prepared = _what_requests_sends(to_requests_code(parse_curl("curl -G 'https://h/a?q=a%20b' -d k=v"))) + + assert prepared.url == "https://h/a?q=a%20b&k=v" diff --git a/test/test_utils/test_diagram_editing.py b/test/test_utils/test_diagram_editing.py new file mode 100644 index 00000000..595e071e --- /dev/null +++ b/test/test_utils/test_diagram_editing.py @@ -0,0 +1,297 @@ +"""Editing a diagram: what the context menu, stacking, undo, panel and wheel act on.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import QApplication, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, DiagramNode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _node(scene: DiagramScene, x: float, text: str, z: float = 0) -> DiagramNode: + node = DiagramNode(x=x, y=0, w=100, h=60, text=text) + node.setZValue(z) + scene.addItem(node) + return node + + +def _top_node(scene: DiagramScene, pos: QPointF) -> DiagramNode: + """The node drawn on top at *pos* (a hit on its label counts as the node).""" + for item in scene.items(pos): + owner = item if isinstance(item, DiagramNode) else item.parentItem() + if isinstance(owner, DiagramNode): + return owner + raise AssertionError("no node there") + + +def _right_click_and_choose(scene: DiagramScene, monkeypatch, pos: QPointF, label: str) -> None: + """Open the context menu at *pos* and pick the entry called *label*.""" + + class Menu: + def __init__(self, *_args) -> None: + self.entries: dict = {} + + def addAction(self, text, slot=None): + self.entries[text] = slot + + def addSeparator(self) -> None: + """Separators are not choices.""" + + def actions(self) -> list: + return list(self.entries) + + def exec(self, *_args) -> None: + self.entries[label]() + + monkeypatch.setattr(scene_module, "QMenu", Menu) + event = QGraphicsSceneContextMenuEvent(QEvent.Type.GraphicsSceneContextMenu) + event.setScenePos(pos) + scene.contextMenuEvent(event) + + +class TestTheContextMenu: + def test_delete_removes_the_node_that_was_clicked(self, app, monkeypatch): + scene = DiagramScene() + selected = _node(scene, 0, "selected") + clicked = _node(scene, 300, "clicked") + selected.setSelected(True) + + _right_click_and_choose(scene, monkeypatch, QPointF(350, 30), "Delete") + + # It used to delete "selected" and leave "clicked". + assert [node.text() for node in scene.get_all_nodes()] == ["selected"] + assert clicked.scene() is None + + def test_a_click_inside_the_selection_keeps_the_selection(self, app, monkeypatch): + scene = DiagramScene() + first = _node(scene, 0, "a") + second = _node(scene, 300, "b") + first.setSelected(True) + second.setSelected(True) + + _right_click_and_choose(scene, monkeypatch, QPointF(350, 30), "Delete") + + assert scene.get_all_nodes() == [] + + +class TestStacking: + def test_bring_to_front_goes_above_every_other_node(self, app): + scene = DiagramScene() + low = _node(scene, 0, "low", z=0) + _node(scene, 10, "high", z=5) + low.setSelected(True) + + scene._change_z(1) + + assert low.zValue() > 5 + + def test_send_to_back_goes_below_every_other_node_keeping_their_order(self, app): + scene = DiagramScene() + _node(scene, 0, "floor", z=-3) + first = _node(scene, 10, "first", z=1) + second = _node(scene, 20, "second", z=2) + first.setSelected(True) + second.setSelected(True) + + scene._change_z(-1) + + assert first.zValue() < second.zValue() < -3 + + def test_undo_keeps_overlapping_nodes_of_equal_z_the_same_way_up(self, app): + scene = DiagramScene() + _node(scene, 0, "under") + _node(scene, 50, "over") # added later, same z: drawn on top + assert _top_node(scene, QPointF(75, 30)).text() == "over" + + with scene.undo_scope("Move"): + scene.get_all_nodes()[0].moveBy(5, 0) + scene.undo_stack.undo() + + assert _top_node(scene, QPointF(75, 30)).text() == "over" + + +class TestUndoAndSizes: + def test_pressing_on_a_selected_image_starts_an_undo_step(self, app): + scene = DiagramScene() + image = DiagramImage(x=0, y=0, w=100, h=100, source="") + scene.addItem(image) + image.setSelected(True) + press = QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMousePress) + press.setScenePos(QPointF(50, 50)) + press.setButton(Qt.MouseButton.LeftButton) + press.setButtons(Qt.MouseButton.LeftButton) + + scene.mousePressEvent(press) + + assert scene._pending_undo_desc == "Move" + + @pytest.mark.parametrize(("saved", "loaded"), [ + (1000, 48), (1, 6), (12.7, 12), + # 1e999 in the file loads as infinity: Open failed with no message + (float("inf"), 10), (float("nan"), 10), ("big", 10), (None, 10), + ]) + def test_a_font_size_from_a_file_is_kept_in_range(self, app, saved, loaded): + node = DiagramNode.from_dict({"x": 0, "y": 0, "text": "a", "font_size": saved}) + + assert node.to_dict(0)["font_size"] == loaded + + def test_the_panel_does_not_shrink_a_large_node(self, app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel + + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=1200, h=900, text="big") + scene.addItem(node) + panel = DiagramPropertyPanel(scene) + node.setSelected(True) + + assert panel._node_w.value() == 1200 + assert panel._node_h.value() == 900 + panel.deleteLater() + + def test_a_scene_already_destroyed_does_not_raise_in_the_panel(self, app): + import shiboken6 + + from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel + + scene = DiagramScene() + panel = DiagramPropertyPanel(scene) + shiboken6.delete(scene) + + panel._on_selection_changed() # raised RuntimeError before + panel.deleteLater() + + +class TestTheWheel: + def _wheel(self, view, dx: int, dy: int) -> None: + event = QWheelEvent( + QPointF(10, 10), QPointF(10, 10), QPoint(0, 0), QPoint(dx, dy), + Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier, + Qt.ScrollPhase.NoScrollPhase, False) + view.wheelEvent(event) + + def test_a_sideways_wheel_does_not_zoom(self, app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView + + view = DiagramView(DiagramScene()) + + self._wheel(view, 120, 0) + + assert view.transform().m11() == 1.0 + self._wheel(view, 0, 120) + assert view.transform().m11() > 1.0 + view.deleteLater() + + +class TestThePropertyPanelAndTheCanvas: + def _selected_node(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_property_panel import DiagramPropertyPanel + + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=140, h=60, text="A") + scene.addItem(node) + panel = DiagramPropertyPanel(scene) + node.setSelected(True) + return scene, node, panel + + def test_a_height_edit_keeps_a_width_dragged_on_the_canvas(self, app): + # The panel still held the width from before the drag, and set it back + from PySide6.QtCore import QPointF, QRectF + + scene, node, panel = self._selected_node() + with scene.undo_scope("Resize"): + node._apply_resize("r", QPointF(200, 0), QRectF(0, 0, 140, 60), QPointF(0, 0)) + + assert panel._node_w.value() == 340 # the panel follows the canvas + panel._node_h.setValue(80) + + assert (node.node_w, node.node_h) == (340, 80) + + def test_steps_on_one_property_are_one_undo_step(self, app): + # Every arrow click was its own step, each with two whole-scene snapshots + scene, node, panel = self._selected_node() + before = scene.undo_stack.count() + + for size in (11, 12, 13, 14, 15): + panel._node_font.setValue(size) + + assert scene.undo_stack.count() == before + 1 + scene.undo_stack.undo() + restored = [item for item in scene.items() if isinstance(item, DiagramNode)][0] + assert restored._font_size != 15 + + def test_another_property_is_its_own_step(self, app): + scene, node, panel = self._selected_node() + before = scene.undo_stack.count() + + panel._node_font.setValue(12) + panel._node_w.setValue(200) + panel._node_font.setValue(13) + + assert scene.undo_stack.count() == before + 3 + + def test_typing_a_size_applies_it_once(self, app): + _scene, _node, panel = self._selected_node() + + assert not panel._node_w.keyboardTracking() + assert not panel._node_font.keyboardTracking() + + +class TestZooming: + """Fit went outside the zoom range, and then no step was allowed either way.""" + + def _view(self, *nodes): + from pybreeze.pybreeze_ui.diagram_editor.diagram_view import DiagramView + + scene = DiagramScene() + for node in nodes: + scene.addItem(node) + view = DiagramView(scene) + view.resize(1200, 800) + return scene, view + + def test_fitting_one_node_stays_within_the_range_and_can_zoom_out(self, app): + from pybreeze.pybreeze_ui.diagram_editor import diagram_view + + scene, view = self._view(DiagramNode(x=0, y=0, w=40, h=20)) + view.resize(4000, 3000) + view.fit(scene.itemsBoundingRect()) + + assert view.transform().m11() == pytest.approx(diagram_view._MAX_SCALE) + view.zoom_out() + assert view.transform().m11() < diagram_view._MAX_SCALE + + def test_fitting_nodes_far_apart_stays_within_the_range(self, app): + from pybreeze.pybreeze_ui.diagram_editor import diagram_view + + scene, view = self._view(DiagramNode(x=0, y=0), DiagramNode(x=50000, y=0)) + view.fit(scene.itemsBoundingRect()) + + assert view.transform().m11() == pytest.approx(diagram_view._MIN_SCALE) + + def test_a_scale_outside_the_range_can_step_back_toward_it(self, app): + from pybreeze.pybreeze_ui.diagram_editor import diagram_view + + _scene, view = self._view(DiagramNode(x=0, y=0)) + view.scale(8, 8) # as a fit used to leave it + + view.zoom_in() + assert view.transform().m11() == pytest.approx(8) # further out: refused + view.zoom_out() + assert view.transform().m11() < 8 + assert diagram_view._MAX_SCALE < 8 + diff --git a/test/test_utils/test_diagram_editor_widget.py b/test/test_utils/test_diagram_editor_widget.py new file mode 100644 index 00000000..800846d4 --- /dev/null +++ b/test/test_utils/test_diagram_editor_widget.py @@ -0,0 +1,231 @@ +"""The diagram editor's own file handling: what opening and saving do to files on disk.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def editor(app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + widget = DiagramEditorWidget() + yield widget + widget.deleteLater() + + +_A_DIAGRAM = { + "nodes": [{"id": 0, "x": 0, "y": 0, "w": 100, "h": 60, "text": "A", "shape": "RECTANGLE"}], + "connections": [], + "images": [], +} + + +class TestSaving: + def test_a_save_that_fails_leaves_the_last_good_file(self, editor, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + from pybreeze.utils.file_process import replace_file + + target = tmp_path / "keep.diagram.json" + target.write_text(json.dumps(_A_DIAGRAM), encoding="utf-8") + editor._scene.load_from_dict(_A_DIAGRAM) + monkeypatch.setattr( + diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: None)) + + def refuse(*_args, **_kwargs): + raise OSError("no room on the disk") + + monkeypatch.setattr(replace_file.os, "replace", refuse) + editor._write_json(target) + + assert json.loads(target.read_text(encoding="utf-8")) == _A_DIAGRAM + assert list(tmp_path.iterdir()) == [target], "a half-written file was left behind" + + def test_a_save_writes_the_diagram(self, editor, tmp_path): + target = tmp_path / "out.diagram.json" + editor._scene.load_from_dict(_A_DIAGRAM) + + editor._write_json(target) + + stored = json.loads(target.read_text(encoding="utf-8")) + assert [node["text"] for node in stored["nodes"]] == ["A"] + + def test_save_as_has_a_button(self, editor, tmp_path, monkeypatch): + # Only Ctrl+Shift+S reached it: once saved, a diagram had no way to a + # new file from the toolbar + from PySide6.QtWidgets import QPushButton + + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import _lang + + first, second = tmp_path / "first.diagram.json", tmp_path / "second.diagram.json" + editor._scene.load_from_dict(_A_DIAGRAM) + editor._current_path = first + monkeypatch.setattr( + diagram_editor_widget.QFileDialog, "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(second), ""))) + button = next(button for button in editor.findChildren(QPushButton) + if button.text() == _lang("diagram_editor_action_save_as")) + + button.click() + + assert second.is_file() and not first.exists() + assert editor._current_path == second + + +class TestOpening: + def test_a_file_that_is_not_a_diagram_leaves_the_canvas_alone( + self, editor, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + editor._scene.load_from_dict(_A_DIAGRAM) + editor._current_path = tmp_path / "work.diagram.json" + editor._current_path.write_text(json.dumps(_A_DIAGRAM), encoding="utf-8") + other = tmp_path / "other.diagram.json" + other.write_text(json.dumps([1, 2]), encoding="utf-8") + said: list = [] + monkeypatch.setattr( + diagram_editor_widget.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(other), ""))) + monkeypatch.setattr( + diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: said.append(args))) + + editor._open_diagram() + + assert said, "the user was told nothing" + assert [n.text() for n in editor._scene.get_all_nodes()] == ["A"] + # The next save must still go to the file that is open, unharmed. + editor._write_json(editor._current_path) + assert json.loads(editor._current_path.read_text(encoding="utf-8"))["nodes"] + + +class TestShortcuts: + def test_every_shortcut_applies_only_while_the_editor_has_focus(self, editor): + # Opened as a dock, the editor shares the window with the code editor; + # window-wide, its Ctrl+D or Ctrl+Z took over, or collided with, the code + # editor's own. + from PySide6.QtCore import Qt + from PySide6.QtGui import QShortcut + + shortcuts = editor.findChildren(QShortcut) + + assert len(shortcuts) >= 11 + assert {shortcut.context() for shortcut in shortcuts} == { + Qt.ShortcutContext.WidgetWithChildrenShortcut} + + +class TestExporting: + """An export replaces the chosen file only once the new one is whole.""" + + def _export(self, editor, monkeypatch, target, kind: str) -> list: + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + warned: list = [] + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(target), ""))) + monkeypatch.setattr(diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: warned.append(args))) + editor._scene.load_from_dict(_A_DIAGRAM) + getattr(editor, f"_export_{kind}")() + return warned + + def test_a_png_export_writes_a_png(self, editor, tmp_path, monkeypatch): + target = tmp_path / "diagram.png" + + assert self._export(editor, monkeypatch, target, "png") == [] + + assert target.read_bytes().startswith(b"\x89PNG") + assert list(tmp_path.iterdir()) == [target] + + def test_an_svg_export_writes_an_svg(self, editor, tmp_path, monkeypatch): + target = tmp_path / "diagram.svg" + + assert self._export(editor, monkeypatch, target, "svg") == [] + + assert " 200 and QColor(image.pixel(x, y)).green() < 60 + for x, y in ((image.width() // 2, image.height() // 2), + (image.width() // 4, image.height() // 2), + (image.width() * 3 // 4, image.height() // 2))] + assert red == [True, True, True] + assert QColor(image.pixel(5, 5)).name() == "#ffffff" + + def test_an_svg_export_views_the_canvas_where_it_is(self, editor, tmp_path, monkeypatch): + # Rendered the same way, the node sat outside the SVG's view box + from PySide6.QtCore import QRectF + from PySide6.QtGui import QColor, QImage, QPainter + from PySide6.QtSvg import QSvgRenderer + + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + target = tmp_path / "diagram.svg" + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(target), ""))) + editor._scene.load_from_dict({"nodes": [{ + "id": 0, "x": 500, "y": 300, "w": 200, "h": 100, "text": "", + "shape": "RECTANGLE", "fill_color": "#ff0000", "border_color": "#ff0000"}]}) + + editor._export_svg() + + renderer = QSvgRenderer(str(target)) + image = QImage(renderer.defaultSize(), QImage.Format.Format_ARGB32) + image.fill(0xFFFFFFFF) + painter = QPainter(image) + renderer.render(painter, QRectF(0, 0, image.width(), image.height())) + painter.end() + middle = QColor(image.pixel(image.width() // 2, image.height() // 2)) + assert middle.red() > 200 and middle.green() < 60 + + @pytest.mark.parametrize("kind", ["png", "svg"]) + def test_an_export_that_fails_leaves_the_previous_one(self, editor, tmp_path, monkeypatch, kind): + # It was written in place: a failure part-way left the last export cut short + from pybreeze.utils.file_process import replace_file + + target = tmp_path / f"diagram.{kind}" + target.write_bytes(b"the previous export") + + def refuse(*_args, **_kwargs): + raise OSError("no room on the disk") + + monkeypatch.setattr(replace_file.os, "replace", refuse) + warned = self._export(editor, monkeypatch, target, kind) + + assert warned + assert target.read_bytes() == b"the previous export" + assert list(tmp_path.iterdir()) == [target], "a half-written file was left behind" diff --git a/test/test_utils/test_diagram_images.py b/test/test_utils/test_diagram_images.py new file mode 100644 index 00000000..64eaa852 --- /dev/null +++ b/test/test_utils/test_diagram_images.py @@ -0,0 +1,355 @@ +"""Where a diagram's images come from, and when the IDE waits for them. + +A URL image is fetched on its own thread and kept for the session: an undo +rebuilds every item from the saved dictionary, and fetching again each time was +15 seconds of frozen IDE per image and per undo. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import threading +import time + +import pytest +from PySide6.QtGui import QColor, QImage +from PySide6.QtWidgets import QApplication + +from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget as editor_module +from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene +from pybreeze.pybreeze_ui.thread_keeper import is_kept + +_A_URL = "https://pictures.example/logo.png" + +_A_DIAGRAM = { + "nodes": [], + "connections": [], + "images": [{"x": 0, "y": 0, "w": 100, "h": 100, "source": _A_URL}], +} + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def an_image() -> bytes: + """A real PNG, as bytes, so QPixmap has something to load.""" + from PySide6.QtCore import QBuffer, QByteArray + + image = QImage(4, 4, QImage.Format.Format_RGB32) + image.fill(QColor("red")) + data = QByteArray() + buffer = QBuffer(data) + buffer.open(QBuffer.OpenModeFlag.WriteOnly) + image.save(buffer, "PNG") + buffer.close() + return bytes(data.data()) + + +@pytest.fixture() +def downloads(monkeypatch) -> list: + """Record every fetch, and answer each with a real image.""" + asked: list = [] + + def fetch(source: str) -> bytes: + asked.append(source) + return an_image() + + monkeypatch.setattr(scene_module, "safe_download_image", fetch) + return asked + + +def wait_for_downloads(scene: DiagramScene, app) -> None: + for thread in tuple(scene._image_downloads.values()): + thread.wait(5000) + app.processEvents() + + +class TestFetchingAnImage: + def test_the_load_does_not_wait_for_the_network(self, app, monkeypatch): + answering = threading.Event() + started = threading.Event() + + def fetch(_source: str) -> bytes: + started.set() + answering.wait(10) + return an_image() + + monkeypatch.setattr(scene_module, "safe_download_image", fetch) + scene = DiagramScene() + + scene.load_from_dict(_A_DIAGRAM) + + # The host has not answered yet and the load is already back. + assert started.wait(5), "the fetch never started" + assert scene._image_downloads, "the load waited for the fetch" + answering.set() + wait_for_downloads(scene, app) + scene.let_image_downloads_run_out() + + def test_what_comes_back_reaches_the_image(self, app, downloads): + scene = DiagramScene() + scene.load_from_dict(_A_DIAGRAM) + + wait_for_downloads(scene, app) + + assert downloads == [_A_URL] + assert scene._pixmap_cache[_A_URL].isNull() is False + scene.let_image_downloads_run_out() + + def test_an_undo_does_not_fetch_it_again(self, app, downloads): + scene = DiagramScene() + scene.load_from_dict(_A_DIAGRAM) + wait_for_downloads(scene, app) + + # What undo/redo does: rebuild every item from the same dictionary. + scene._restore_from_dict(_A_DIAGRAM) + scene._restore_from_dict(_A_DIAGRAM) + + assert downloads == [_A_URL], "the image was fetched again" + images = scene.get_all_images() + assert images and not images[0]._pix_item.pixmap().isNull() + scene.let_image_downloads_run_out() + + def test_two_images_from_one_source_share_a_single_fetch(self, app, downloads): + scene = DiagramScene() + + scene.load_from_dict({ + **_A_DIAGRAM, + "images": [ + {"x": 0, "y": 0, "w": 100, "h": 100, "source": _A_URL}, + {"x": 200, "y": 0, "w": 100, "h": 100, "source": _A_URL}, + ], + }) + wait_for_downloads(scene, app) + + assert downloads == [_A_URL] + scene.let_image_downloads_run_out() + + def test_a_fetch_that_fails_is_only_logged(self, app, monkeypatch): + def refuse(source: str) -> bytes: + raise scene_module.ImageDownloadError("nothing answered") + + monkeypatch.setattr(scene_module, "safe_download_image", refuse) + scene = DiagramScene() + + scene.load_from_dict(_A_DIAGRAM) + wait_for_downloads(scene, app) + + assert _A_URL not in scene._pixmap_cache + assert scene.get_all_images() + scene.let_image_downloads_run_out() + + +def _editor(): + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + update_language_dict() + return DiagramEditorWidget() + + +def _wait_until(app, condition) -> None: + deadline = time.monotonic() + 5 + while not condition(): + assert time.monotonic() < deadline, "timed out" + app.processEvents() + time.sleep(0.01) + + +@pytest.fixture() +def slow_host(monkeypatch) -> threading.Event: + """A host that answers with an image once the returned event is set.""" + answering = threading.Event() + + def fetch(_source: str) -> bytes: + answering.wait(5) + return an_image() + + monkeypatch.setattr(scene_module, "safe_download_image", fetch) + return answering + + +class TestClosingTheEditor: + def test_it_lets_a_fetch_still_going_run_out(self, app, slow_host): + editor = _editor() + editor._scene.load_from_dict(_A_DIAGRAM) + (fetch,) = editor._scene._image_downloads.values() + + editor.close() # returns while the host is still answering + + assert editor._scene._image_downloads == {} + assert is_kept(fetch) + slow_host.set() + _wait_until(app, lambda: not is_kept(fetch)) + + +class TestAddingAnImageFromAUrl: + def _ask_for(self, monkeypatch, url: str) -> None: + monkeypatch.setattr(editor_module.QInputDialog, "getText", lambda *args: (url, True)) + + def test_the_image_arrives_without_holding_the_ui(self, app, monkeypatch, slow_host): + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + + editor._add_image_from_url() + assert editor._scene.get_all_images() == [] # back before the host answered + slow_host.set() + _wait_until(app, lambda: editor._scene.get_all_images() and not editor._url_fetches) + + assert editor._scene.get_all_images()[0].source() == _A_URL + editor.close() + + def test_a_failed_fetch_is_reported(self, app, monkeypatch): + def refuse(_source: str) -> bytes: + raise scene_module.ImageDownloadError("nothing answered") + + monkeypatch.setattr(scene_module, "safe_download_image", refuse) + warned = [] + monkeypatch.setattr(editor_module.QMessageBox, "warning", lambda *args: warned.append(args[2])) + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + + editor._add_image_from_url() + _wait_until(app, lambda: warned) + + # As text: the message quotes the server, and Qt read markup in it + from pybreeze.pybreeze_ui.plain_text import as_text + + assert warned == [as_text("nothing answered")] + assert editor._scene.get_all_images() == [] + editor.close() + + def test_something_that_is_not_an_image_is_reported(self, app, monkeypatch): + monkeypatch.setattr(scene_module, "safe_download_image", lambda _source: b"") + warned = [] + monkeypatch.setattr(editor_module.QMessageBox, "warning", lambda *args: warned.append(args[2])) + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + + editor._add_image_from_url() + _wait_until(app, lambda: warned) + + assert editor._scene.get_all_images() == [] + editor.close() + + def test_closing_mid_fetch_lets_it_run_out(self, app, monkeypatch, slow_host): + editor = _editor() + self._ask_for(monkeypatch, _A_URL) + editor._add_image_from_url() + (fetch,) = editor._url_fetches + + editor.close() + + assert is_kept(fetch) + slow_host.set() + _wait_until(app, lambda: not is_kept(fetch)) + + +class TestCopyingAnImage: + def _scene_with_an_image(self): + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [], "connections": [], + "images": [{"x": 0, "y": 0, "w": 100, "h": 100, "source": "", "caption": "Logo"}], + }) + return scene + + def test_an_image_on_its_own_can_be_duplicated(self, app): + scene = self._scene_with_an_image() + scene.get_all_images()[0].setSelected(True) + + scene.duplicate_selected() + + images = scene.get_all_images() + assert len(images) == 2 + assert {image.text() for image in images} == {"Logo"} + assert {(image.pos().x(), image.pos().y()) for image in images} == {(0.0, 0.0), (30.0, 30.0)} + + def test_a_node_and_an_image_are_copied_together(self, app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode, NodeShape + + scene = self._scene_with_an_image() + node = DiagramNode(x=0, y=0, w=100, h=60, text="A", shape=NodeShape.RECTANGLE) + scene.addItem(node) + for item in scene.get_all_images() + scene.get_all_nodes(): + item.setSelected(True) + + scene.copy_selected() + scene.clearSelection() + scene.paste_clipboard() + + assert len(scene.get_all_images()) == 2 + assert len(scene.get_all_nodes()) == 2 + + def test_nothing_selected_leaves_the_clipboard_alone(self, app): + scene = self._scene_with_an_image() + scene.get_all_images()[0].setSelected(True) + scene.copy_selected() + + scene.clearSelection() + scene.copy_selected() + + assert scene._clipboard["images"], "the clipboard was emptied by a copy of nothing" + + +class _Drag: + """The parts of a mouse event a resize handle reads.""" + + def __init__(self, x: float, y: float) -> None: + from PySide6.QtCore import QPointF + + self._pos = QPointF(x, y) + + def scenePos(self): + return self._pos + + @staticmethod + def button(): + from PySide6.QtCore import Qt + + return Qt.MouseButton.LeftButton + + def accept(self) -> None: + """Handled.""" + + +def test_an_image_resizes_by_its_corner_handle(app): + # The handle read node_w, which an image does not have: every press and + # move raised, and only the property panel could resize an image + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage + + scene = DiagramScene() + image = DiagramImage(x=0, y=0, w=100, h=80) + scene.addItem(image) + handle = image._handles["br"] + + handle.mousePressEvent(_Drag(100, 80)) + handle.mouseMoveEvent(_Drag(150, 130)) + handle.mouseReleaseEvent(_Drag(150, 130)) + + assert (image.img_w, image.img_h) == (150, 130) + assert scene.undo_stack.count() == 1 + + +def test_an_image_resized_down_and_back_is_as_sharp_as_before(app): + # Each resize scaled the copy on show: 400 -> 50 -> 400 left it blurred + from PySide6.QtGui import QPixmap + + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage + + checkers = QImage(400, 400, QImage.Format.Format_RGB32) + for y in range(400): + for x in range(400): + checkers.setPixelColor(x, y, QColor("black") if (x // 20 + y // 20) % 2 else QColor("white")) + item = DiagramImage(0, 0, 400, 400, pixmap=QPixmap.fromImage(checkers)) + before = item._pix_item.pixmap().toImage() + + item.set_size(50, 50) + item.set_size(400, 400) + + assert item._pix_item.pixmap().toImage() == before diff --git a/test/test_utils/test_diagram_serialization.py b/test/test_utils/test_diagram_serialization.py index 7344b13f..31413fb8 100644 --- a/test/test_utils/test_diagram_serialization.py +++ b/test/test_utils/test_diagram_serialization.py @@ -88,6 +88,52 @@ def test_valid_items_load_despite_malformed_entries(self, qt_app): # Only the valid self-connection survives. assert len(scene.get_all_connections()) == 1 + @pytest.mark.parametrize("entry", ["x", 5, None, ["x", 0]]) + def test_an_entry_that_is_not_an_object_is_skipped(self, qt_app, entry): + # An image entry "x" raised AttributeError out of Open, with no message + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0, "text": "Good"}, entry], + "connections": [entry], + "images": [entry], + }) + + assert [n.text() for n in scene.get_all_nodes()] == ["Good"] + assert scene.get_all_connections() == [] + + @pytest.mark.parametrize("source", [5, None, ["a.png"], {"x": 1}]) + def test_an_image_source_that_is_not_text_is_dropped(self, qt_app, source): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0, "text": "Kept"}], + "images": [{"x": 0, "y": 0, "w": 10, "h": 10, "source": source}], + }) + + # It used to raise TypeError after the canvas was cleared. + assert [node.text() for node in scene.get_all_nodes()] == ["Kept"] + (image,) = scene.get_all_images() + assert image.source() == "" + + def test_a_load_that_fails_part_way_leaves_the_canvas_as_it_was(self, qt_app, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({"nodes": [{"id": 0, "x": 0, "y": 0, "text": "Before"}]}) + + def broken(_image_dicts): + raise RuntimeError("something nobody foresaw") + + monkeypatch.setattr(scene, "_load_images", broken) + with pytest.raises(RuntimeError): + scene.load_from_dict({"nodes": [{"id": 0, "x": 0, "y": 0, "text": "After"}], "images": []}) + monkeypatch.undo() + + assert [node.text() for node in scene.get_all_nodes()] == ["Before"] + def test_invalid_connection_style_falls_back_to_solid(self, qt_app): from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ConnectionStyle @@ -197,9 +243,9 @@ def test_invalid_colour_falls_back(self, qt_app): assert node._fill_color.isValid() def test_valid_colour_preserved(self, qt_app): - from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode, NodeStyle - node = DiagramNode(fill_color="#ff0000") + node = DiagramNode(style=NodeStyle(fill_color="#ff0000")) assert node._fill_color.name() == "#ff0000" @@ -239,3 +285,363 @@ def test_parse_then_load_preserves_shapes_arrows_styles(self, qt_app): assert {"DB", "B", "C", "Decision"} == texts styles = sorted(c._style.name for c in scene.get_all_connections()) assert styles == ["DOTTED", "DOTTED", "SOLID"] # two dotted fan-out, one thick + + +class TestALoadThatCannotWorkLeavesTheDiagramAlone: + """What is on the canvas must survive a file that turns out not to be a diagram. + + The editor keeps the path it opened last and saves back to it, so a load that + empties the canvas half-way is a file the next save would overwrite with the + wreckage. + """ + + _GOOD = { + "nodes": [ + {"id": 0, "x": 0, "y": 0, "w": 100, "h": 60, "text": "A", "shape": "RECTANGLE"}, + {"id": 1, "x": 200, "y": 0, "w": 100, "h": 60, "text": "B", "shape": "RECTANGLE"}, + ], + "connections": [{"source": 0, "target": 1}], + "images": [], + } + + def _loaded_scene(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict(self._GOOD) + return scene + + @pytest.mark.parametrize("data", [ + [1, 2], + "a diagram", + {"nodes": "not a list"}, + {"nodes": [], "connections": {"source": 0}}, + ], ids=["top level list", "top level string", "nodes not a list", "connections not a list"]) + def test_a_file_that_is_not_a_diagram_is_refused_before_anything_is_cleared(self, qt_app, data): + scene = self._loaded_scene() + + with pytest.raises(ValueError): + scene.load_from_dict(data) + + assert {n.text() for n in scene.get_all_nodes()} == {"A", "B"} + assert len(scene.get_all_connections()) == 1 + + @pytest.mark.parametrize("connection", [ + {"source": [], "target": 1}, + "a string where an object belongs", + {"source": 0, "target": 1, "style": []}, + ], ids=["unhashable source", "string entry", "unhashable style"]) + def test_one_bad_connection_costs_only_that_connection(self, qt_app, connection): + scene = self._loaded_scene() + data = {**self._GOOD, "connections": [{"source": 0, "target": 1}, connection]} + + scene.load_from_dict(data) + + assert {n.text() for n in scene.get_all_nodes()} == {"A", "B"} + assert len(scene.get_all_connections()) == 1 + + @pytest.mark.parametrize("connection,attribute,expected", [ + ({"source": 0, "target": 1, "line_width": "3"}, "_line_width", 3.0), + ({"source": 0, "target": 1, "line_width": "wide"}, "_line_width", 2.0), + ], ids=["a width written as text", "a width that is not a number"]) + def test_a_connection_with_an_odd_pen_still_loads( + self, qt_app, connection, attribute, expected): + scene = self._loaded_scene() + + scene.load_from_dict({**self._GOOD, "connections": [connection]}) + + loaded = scene.get_all_connections() + assert len(loaded) == 1 + assert getattr(loaded[0], attribute) == expected + + def test_a_colour_that_is_not_text_falls_back_and_still_loads(self, qt_app): + scene = self._loaded_scene() + + scene.load_from_dict( + {**self._GOOD, "connections": [{"source": 0, "target": 1, "line_color": 5}]}) + + loaded = scene.get_all_connections() + assert len(loaded) == 1 + assert loaded[0]._line_color.name() == "#37474f" + + def test_a_node_with_an_unknown_shape_keeps_its_place_as_a_rectangle(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import NodeShape + + scene = self._loaded_scene() + + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0, "text": "Odd", "shape": "hexagon"}], + "connections": [], + }) + + nodes = scene.get_all_nodes() + assert [n.text() for n in nodes] == ["Odd"] + assert nodes[0].shape_type is NodeShape.RECTANGLE + + def test_an_unhashable_node_id_costs_only_that_node(self, qt_app): + scene = self._loaded_scene() + + scene.load_from_dict({ + "nodes": [{"id": [], "x": 0, "y": 0, "text": "Odd"}, + {"id": 2, "x": 0, "y": 0, "text": "Fine"}], + "connections": [], + }) + + assert {n.text() for n in scene.get_all_nodes()} == {"Odd", "Fine"} + + +class TestSizesAndPensFromAFile: + def test_an_enormous_image_is_clamped(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, MAX_ITEM_SIZE + + image = DiagramImage.from_dict({"x": 0, "y": 0, "w": 40000, "h": 1e30}) + + assert image.img_w == MAX_ITEM_SIZE + assert image.img_h == MAX_ITEM_SIZE + + def test_a_negative_image_size_is_clamped(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage + + image = DiagramImage.from_dict({"x": 0, "y": 0, "w": -5, "h": -5}) + + assert image.img_w > 0 + assert image.img_h > 0 + + def test_an_enormous_node_is_clamped(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + DiagramNode, MAX_ITEM_SIZE, + ) + + node = DiagramNode.from_dict( + {"x": 0, "y": 0, "w": 1e12, "h": 1e12, "text": "Big", "shape": "RECTANGLE"}) + + assert node.node_w == MAX_ITEM_SIZE + assert node.node_h == MAX_ITEM_SIZE + + def test_a_wild_line_width_is_clamped_like_the_setter_does(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + DiagramConnection, DiagramNode, NodeShape, + ) + + node = DiagramNode(x=0, y=0, w=100, h=60, text="A", shape=NodeShape.RECTANGLE) + connection = DiagramConnection(node, node, line_width=1e9) + + assert connection._line_width == 10.0 + + def test_an_unparseable_line_colour_falls_back(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import ( + DiagramConnection, DiagramNode, NodeShape, + ) + + node = DiagramNode(x=0, y=0, w=100, h=60, text="A", shape=NodeShape.RECTANGLE) + connection = DiagramConnection(node, node, line_color="not-a-colour") + + assert connection._line_color.isValid() + assert connection._line_color.name() != "#000000" + + +class TestWhereAnImageMayComeFrom: + """A saved diagram names its images; where those names may point is a boundary.""" + + def _scene_with_source(self, monkeypatch, source: str): + from pybreeze.pybreeze_ui.diagram_editor import diagram_scene as scene_module + + looked_at: list = [] + monkeypatch.setattr( + scene_module.Path, "is_file", + lambda self: looked_at.append(str(self)) or False) + scene = scene_module.DiagramScene() + scene.load_from_dict({ + "nodes": [], "connections": [], + "images": [{"x": 0, "y": 0, "w": 100, "h": 100, "source": source}], + }) + return looked_at + + @pytest.mark.parametrize("source", [ + "\\\\attacker.example\\share\\x.png", + "//attacker.example/share/x.png", + ], ids=["windows unc", "forward slash unc"]) + def test_a_path_on_another_machine_is_never_touched(self, qt_app, monkeypatch, source): + # Asking whether a UNC path is a file makes Windows authenticate to that + # host, so a diagram from someone else must not get that far. + assert self._scene_with_source(monkeypatch, source) == [] + + def test_a_name_that_is_not_an_image_is_never_touched(self, qt_app, monkeypatch): + assert self._scene_with_source(monkeypatch, r"C:\Windows\System32\config\SAM") == [] + + def test_a_local_image_path_is_still_looked_up(self, qt_app, monkeypatch): + assert self._scene_with_source(monkeypatch, r"C:\pictures\logo.png") == [ + r"C:\pictures\logo.png"] + + +class TestTheUndoScope: + def test_a_scope_whose_body_raises_leaves_nothing_pending(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode, NodeShape + + scene = DiagramScene() + with pytest.raises(RuntimeError): + with scene.undo_scope("Half done"): + scene.addItem(DiagramNode(x=0, y=0, text="A", shape=NodeShape.RECTANGLE)) + raise RuntimeError("something in the middle went wrong") + + assert scene._pending_undo_snapshot is None + pushed = scene.undo_stack.count() + # The next gesture ends with end_undo(); with a scope left open it would + # push a command undoing everything since. + scene.end_undo() + assert scene.undo_stack.count() == pushed + + +class TestStackingOrder: + def _scene_with_two_nodes(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [ + {"id": 0, "x": 0, "y": 0, "text": "A", "shape": "RECTANGLE"}, + {"id": 1, "x": 10, "y": 10, "text": "B", "shape": "RECTANGLE"}, + ], + "connections": [], + }) + return scene + + def test_it_survives_a_save_and_a_load(self, qt_app): + scene = self._scene_with_two_nodes() + front = [n for n in scene.get_all_nodes() if n.text() == "B"][0] + front.setZValue(3) + + stored = scene.to_dict() + scene.load_from_dict(stored) + + assert {n.text(): n.zValue() for n in scene.get_all_nodes()} == {"A": 0.0, "B": 3.0} + + def test_bringing_a_node_to_the_front_can_be_undone(self, qt_app): + scene = self._scene_with_two_nodes() + node = [n for n in scene.get_all_nodes() if n.text() == "B"][0] + node.setSelected(True) + + scene._change_z(1) + assert [n.zValue() for n in scene.get_all_nodes() if n.text() == "B"] == [1.0] + + scene.undo_stack.undo() + assert [n.zValue() for n in scene.get_all_nodes() if n.text() == "B"] == [0.0] + + def test_a_z_that_is_not_a_number_is_ignored(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + + node = DiagramNode.from_dict({"x": 0, "y": 0, "text": "A", "z": "front"}) + + assert node.zValue() == 0.0 + + +class TestWhatAFileCannotDo: + """A .diagram.json is anyone's to edit: what it says is checked before it is drawn.""" + + @pytest.mark.parametrize("x", [float("nan"), float("inf"), "left"]) + def test_an_item_placed_nowhere_is_skipped(self, qt_app, x): + # NaN loaded as it was: an invisible item, a NaN bounding rect, and no + # export would work again + import math + + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": x, "y": 0}, {"id": 1, "x": 10, "y": 10}], + "images": [{"x": x, "y": 0, "source": ""}], + }) + + assert len(scene.to_dict()["nodes"]) == 1 + assert scene.to_dict()["images"] == [] + rect = scene.itemsBoundingRect() + assert all(math.isfinite(v) for v in (rect.x(), rect.y(), rect.width(), rect.height())) + + def test_an_item_placed_absurdly_far_is_brought_within_reach(self, qt_app): + # 1e308 made the export's image size infinite + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import MAX_COORDINATE + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({"nodes": [{"id": 0, "x": 1e308, "y": -1e308}]}) + + node = scene.to_dict()["nodes"][0] + assert (node["x"], node["y"]) == (MAX_COORDINATE, -MAX_COORDINATE) + + def test_a_connection_that_cannot_be_built_leaves_its_nodes_alone(self, qt_app): + # It stayed registered on both nodes while never being in the scene + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({ + "nodes": [{"id": 0, "x": 0, "y": 0}, {"id": 1, "x": 300, "y": 0}], + "connections": [{"source": 0, "target": 1, "label": 5}], + }) + + nodes = [item for item in scene.items() if isinstance(item, DiagramNode)] + assert [len(node.connections) for node in nodes] == [0, 0] + assert scene.to_dict()["connections"] == [] + + +class TestImagesAndNodesKeepTheirStacking: + """An image under a node stays under it: saved, loaded, undone, or brought forward.""" + + def _image_then_node(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + image = DiagramImage(x=0, y=0, w=100, h=100) + scene.addItem(image) + node = DiagramNode(x=10, y=10, text="Over") + scene.addItem(node) + return scene + + @staticmethod + def _kinds_bottom_first(scene) -> list[str]: + return [type(item).__name__ for item in scene._stackable()] + + def test_a_reload_keeps_the_node_on_top(self, qt_app): + # A load added every node before every image: the image came back on top + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + saved = self._image_then_node().to_dict() + scene = DiagramScene() + scene.load_from_dict(saved) + + assert self._kinds_bottom_first(scene) == ["DiagramImage", "DiagramNode"] + + def test_an_unrelated_undo_keeps_the_node_on_top(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + + scene = self._image_then_node() + with scene.undo_scope("Add"): + scene.addItem(DiagramNode(x=500, y=500, text="Elsewhere")) + scene.undo_stack.undo() + + assert self._kinds_bottom_first(scene) == ["DiagramImage", "DiagramNode"] + + def test_bring_to_front_can_put_a_node_above_an_image(self, qt_app): + # With one node there was no other node to go above, so nothing moved + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramImage, DiagramNode + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + node = DiagramNode(x=10, y=10) + scene.addItem(node) + scene.addItem(DiagramImage(x=0, y=0, w=100, h=100)) + node.setSelected(True) + + scene._change_z(1) + + assert self._kinds_bottom_first(scene) == ["DiagramImage", "DiagramNode"] + + def test_a_file_without_stacking_loads_as_before(self, qt_app): + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + scene = DiagramScene() + scene.load_from_dict({"nodes": [{"id": 0, "x": 0, "y": 0}], "images": [{"x": 0, "y": 0}]}) + + assert self._kinds_bottom_first(scene) == ["DiagramNode", "DiagramImage"] diff --git a/test/test_utils/test_diagram_undo_fidelity.py b/test/test_utils/test_diagram_undo_fidelity.py new file mode 100644 index 00000000..14f6c19f --- /dev/null +++ b/test/test_utils/test_diagram_undo_fidelity.py @@ -0,0 +1,137 @@ +"""Undo in the diagram editor gives back what was there: typed text, and images.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QEvent, QPointF, Qt +from PySide6.QtGui import QColor, QFocusEvent, QPixmap +from PySide6.QtWidgets import QApplication, QGraphicsSceneMouseEvent + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode +from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import DiagramScene + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _type_into(label, text: str) -> None: + """Double-click the label, replace its text, and click away.""" + label.mouseDoubleClickEvent(QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMouseDoubleClick)) + label.setPlainText(text) + label.focusOutEvent(QFocusEvent(QEvent.Type.FocusOut)) + + +class TestTextTypedOnTheCanvas: + def _scene_with_a_node(self): + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=100, h=60, text="Node") + scene.addItem(node) + return scene + + def test_it_is_one_undo_step(self, app): + scene = self._scene_with_a_node() + + _type_into(scene.get_all_nodes()[0].label, "Hello") + + assert scene.undo_stack.count() == 1 + scene.undo_stack.undo() + assert scene.get_all_nodes()[0].text() == "Node" + + def test_undo_then_redo_brings_it_back(self, app): + scene = self._scene_with_a_node() + with scene.undo_scope("Move"): + scene.get_all_nodes()[0].moveBy(10, 0) + _type_into(scene.get_all_nodes()[0].label, "Hello") + + scene.undo_stack.undo() + scene.undo_stack.redo() + + # Undo went back to before the move and redo to just after it: the + # typed text was in neither snapshot, and was gone for good. + assert scene.get_all_nodes()[0].text() == "Hello" + + def test_opening_the_editor_without_typing_records_nothing(self, app): + scene = self._scene_with_a_node() + + _type_into(scene.get_all_nodes()[0].label, "Node") + + assert scene.undo_stack.count() == 0 + + +class TestAnImageTheEditorCannotReload: + def test_undo_keeps_its_picture(self, app, monkeypatch): + scene = DiagramScene() + pixmap = QPixmap(20, 10) + pixmap.fill(QColor("red")) + downloads: list = [] + monkeypatch.setattr(scene, "_start_image_download", downloads.append) + # A .tiff picked through "All Files", and an image added from a URL + scene.add_image(pixmap, r"C:\scans\page.tiff", QPointF(0, 0)) + scene.add_image(pixmap, "https://img.example/logo.png", QPointF(100, 0)) + with scene.undo_scope("Move"): + scene.get_all_images()[0].moveBy(5, 0) + + scene.undo_stack.undo() + + # The first went blank (.tiff is not on the list the editor reloads), + # and the second was downloaded again. + assert all(not image._pix_item.pixmap().isNull() for image in scene.get_all_images()) + assert downloads == [] + + +def test_a_mouse_move_is_still_one_step(app): + scene = DiagramScene() + node = DiagramNode(x=0, y=0, w=100, h=60, text="Node") + scene.addItem(node) + node.setSelected(True) + press = QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMousePress) + press.setScenePos(QPointF(50, 30)) + press.setButton(Qt.MouseButton.LeftButton) + press.setButtons(Qt.MouseButton.LeftButton) + scene.mousePressEvent(press) + node.moveBy(20, 0) + + scene.end_undo() + + assert scene.undo_stack.count() == 1 + + +def _click(scene: DiagramScene, x: float, y: float) -> None: + press = QGraphicsSceneMouseEvent(QEvent.Type.GraphicsSceneMousePress) + press.setScenePos(QPointF(x, y)) + press.setButton(Qt.MouseButton.LeftButton) + press.setButtons(Qt.MouseButton.LeftButton) + scene.mousePressEvent(press) + + +@pytest.mark.parametrize("rebuild", ["undo and redo", "load"]) +def test_a_half_made_connection_does_not_outlive_a_rebuild(app, rebuild): + # Its first node was replaced by the rebuild; finished, the connection + # pointed at a node no longer on the canvas, and every snapshot (so Save, + # and Delete) raised KeyError from then on + from pybreeze.pybreeze_ui.diagram_editor.diagram_scene import ToolMode + + scene = DiagramScene() + with scene.undo_scope("Add"): + scene.addItem(DiagramNode(x=0, y=0, w=100, h=60, text="A")) + scene.addItem(DiagramNode(x=300, y=0, w=100, h=60, text="B")) + scene.mode = ToolMode.ADD_CONNECTION + _click(scene, 50, 30) + if rebuild == "load": + scene.load_from_dict(scene.to_dict()) + else: + scene.undo_stack.undo() + scene.undo_stack.redo() + + _click(scene, 350, 30) + + assert scene.get_all_connections() == [] + scene.to_dict() diff --git a/test/test_utils/test_diff_gui.py b/test/test_utils/test_diff_gui.py index 8c2b8e49..42df3fe3 100644 --- a/test/test_utils/test_diff_gui.py +++ b/test/test_utils/test_diff_gui.py @@ -27,11 +27,25 @@ def widget(app): gui.deleteLater() +def _compare(widget, *, start: bool = True) -> None: + """Compare (unless *start* is false), and wait for the result: it is worked out off the UI thread.""" + import time + + if start: + widget.compare() + deadline = time.monotonic() + 120 + while widget._compare_thread.isRunning() and time.monotonic() < deadline: + QApplication.processEvents() + time.sleep(0.01) + widget._compare_thread.wait(1000) + QApplication.processEvents() + + class TestDiffGUI: def test_shows_diff(self, widget): widget.left_edit.setPlainText("a\nb") widget.right_edit.setPlainText("a\nc") - widget.compare() + _compare(widget) output = widget.output_edit.toPlainText() assert "-b" in output assert "+c" in output @@ -39,21 +53,21 @@ def test_shows_diff(self, widget): def test_identical_summary(self, widget): widget.left_edit.setPlainText("same") widget.right_edit.setPlainText("same") - widget.compare() + _compare(widget) assert widget.summary_label.text() != "" assert widget.output_edit.toPlainText() == "" def test_summary_counts(self, widget): widget.left_edit.setPlainText("a") widget.right_edit.setPlainText("a\nb") - widget.compare() + _compare(widget) # The summary mentions one added line. assert "1" in widget.summary_label.text() def test_copy_output(self, app, widget): widget.left_edit.setPlainText("a") widget.right_edit.setPlainText("b") - widget.compare() + _compare(widget) widget.actions.copy() assert QApplication.clipboard().text() != "" @@ -62,3 +76,41 @@ def test_build_summary_line_identical(self, app): from pybreeze.utils.diff_tools.text_diff import DiffSummary text = build_summary_line(DiffSummary(added=0, removed=0, is_equal=True)) assert text != "" + + +class TestALargeComparison: + def test_the_window_keeps_answering_while_it_is_compared(self, widget): + # Large texts took seconds to compare (40,000 lines over four), on the UI thread + import time + + rows = [f' {{"id": {i}, "value": {i * 7 % 13}}},' for i in range(20000)] + widget.left_edit.setPlainText("\n".join(rows)) + rows[::10] = [row.replace("value", "VALUE") for row in rows[::10]] + widget.right_edit.setPlainText("\n".join(rows)) + + started = time.monotonic() + widget.compare() + returned = time.monotonic() - started + _compare(widget, start=False) + + assert returned < 0.5 + assert "2000 line(s) added" in widget.summary_label.text() + + def test_a_second_compare_while_one_runs_is_ignored(self, widget, monkeypatch): + import threading + + from pybreeze.pybreeze_ui.tools_gui import diff_gui + + release = threading.Event() + real = diff_gui.compare_texts + monkeypatch.setattr(diff_gui, "compare_texts", lambda left, right: release.wait(10) and real(left, right)) + widget.left_edit.setPlainText("a") + widget.right_edit.setPlainText("b") + widget.compare() + first = widget._compare_thread + widget.compare() + + assert widget._compare_thread is first + release.set() + _compare(widget, start=False) + assert "+b" in widget.output_edit.toPlainText() diff --git a/test/test_utils/test_error_text.py b/test/test_utils/test_error_text.py new file mode 100644 index 00000000..bec3a280 --- /dev/null +++ b/test/test_utils/test_error_text.py @@ -0,0 +1,217 @@ +"""Why a tool refused its input, shown in the IDE language rather than always in English.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as CHINESE, +) +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui import error_text as error_text_mod +from pybreeze.pybreeze_ui.error_text import error_text +from pybreeze.utils.exception import exception_tags +from pybreeze.utils.exception.error_templates import ERROR_TEXT_KEY_PREFIX, TEMPLATE_FIELD, error_templates + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def chinese(monkeypatch): + monkeypatch.setattr(error_text_mod.language_wrapper, "language_word_dict", CHINESE) + + +def _sample(template: str) -> dict[str, str]: + return {field.group(1): f"<{field.group(1)}>" for field in TEMPLATE_FIELD.finditer(template)} + + +class TestEveryConstant: + def test_has_a_chinese_translation(self): + missing = [name for name in error_templates() if ERROR_TEXT_KEY_PREFIX + name not in CHINESE] + assert not missing + + @pytest.mark.parametrize("name", sorted(error_templates())) + def test_is_found_again_from_the_message(self, chinese, name): + constant = getattr(exception_tags, name) + fields = _sample(error_templates()[name]) + message = constant.format(**fields) + # A field written {key!r} is in the message as its repr + shown = {field: repr(value) if "{" + field + "!r}" in constant else value + for field, value in fields.items()} + + assert error_text(message) == CHINESE[ERROR_TEXT_KEY_PREFIX + name].format(**shown) + + +class TestWhatIsKept: + def test_a_value_repr_d_into_the_message(self, chinese): + message = exception_tags.json_duplicate_key_error.format(key="a") + + assert error_text(message) == "這份 JSON 在同一個物件裡給了兩次 'a' 鍵" + + def test_a_detail_after_the_constant(self, chinese): + message = f"{exception_tags.cant_reformat_json_error} (Expecting value)" + + assert error_text(message) == "無法重新格式化 JSON:型別正確嗎? (Expecting value)" + + def test_braces_in_a_value_are_not_read_as_fields(self, chinese): + message = exception_tags.invalid_regex_pattern_error.format(detail="bad {0} here") + + assert error_text(message) == "無效的正規表示式:bad {0} here" + + def test_a_message_from_elsewhere(self, chinese): + assert error_text("[Errno 2] No such file") == "[Errno 2] No such file" + + def test_english_is_the_constant_itself(self, app): + message = exception_tags.json_duplicate_key_error.format(key="'a'") + + assert error_text(message) == message + + +class TestATab: + def test_shows_the_reason_in_chinese(self, app, chinese, monkeypatch): + # "轉換失敗:not a recognized epoch number or ISO-8601 date-time" + from pybreeze.pybreeze_ui.tools_gui import timestamp_gui + from pybreeze.pybreeze_ui.tools_gui.timestamp_gui import TimestampGUI + + monkeypatch.setattr(timestamp_gui.language_wrapper, "language_word_dict", CHINESE) + tab = TimestampGUI() + tab.input_edit.setText("not a time") + + tab.convert() + + shown = tab.output_edit.toPlainText() + assert "無法辨識為 epoch 數值或 ISO-8601 日期時間" in shown + assert "recognized" not in shown + tab.deleteLater() + + def test_a_har_file_that_is_not_one(self, app, chinese, monkeypatch): + from pybreeze.pybreeze_ui.tools_gui import har_import_gui + from pybreeze.pybreeze_ui.tools_gui.har_import_gui import HarImportGUI + + monkeypatch.setattr(har_import_gui.language_wrapper, "language_word_dict", CHINESE) + tab = HarImportGUI() + + assert not tab.load_text('{"log": {}}') + + shown = tab.output_edit.toPlainText() + assert "這份 JSON 沒有 log.entries 清單,不是 HAR 匯出檔" in shown + assert "entries list" not in shown + tab.deleteLater() + + +class TestTheNetworkReasons: + """URL checks, request failures, image downloads, host keys and Skills' status texts.""" + + @pytest.mark.parametrize(("status", "headers", "body", "expected"), [ + (302, {"Location": "https://elsewhere.example/x?key=1"}, "", "重新導向(未跟隨)至 https://elsewhere.example"), + (302, {"Location": "/next"}, "", "重新導向(未跟隨)至這台伺服器上的另一個路徑"), + (401, {}, "", "驗證或授權失敗"), + (503, {}, "overloaded", "伺服器錯誤:overloaded"), + ]) + def test_a_skills_answer_that_is_not_2xx(self, chinese, status, headers, body, expected): + from types import SimpleNamespace + + from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import describe_failed_status + + response = SimpleNamespace(status_code=status, headers=headers, is_redirect=status == 302) + + assert describe_failed_status(response, body)[1] == expected + + def test_an_image_that_did_not_download(self, app, chinese, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_scene + from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import ImageDownloadError + + def refuse(_source): + raise ImageDownloadError(exception_tags.image_too_large_error.format(megabytes=20)) + + monkeypatch.setattr(diagram_scene, "safe_download_image", refuse) + thread = diagram_scene.ImageDownloadThread("https://example.org/a.png") + failed: list = [] + thread.failed.connect(lambda _source, message: failed.append(message)) + + thread.run() # on this thread: a direct connection + + assert failed == ["圖片超過 20 MB 的上限。"] + + def test_a_url_the_ssrf_check_refuses(self, chinese): + from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url + + with pytest.raises(UnsafeURLError) as refused: + validate_url("ftp://example.org/") + + assert error_text(str(refused.value)) == "不允許 'ftp' 協定,請使用 http 或 https。" + + def test_a_request_that_timed_out(self, chinese): + import requests + + from pybreeze.utils.network.http_client import describe_request_error + + assert error_text(describe_request_error(requests.ReadTimeout())) == "請求逾時 (ReadTimeout)" + + def test_a_host_key_the_user_declined(self, app, chinese): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget + + widget = SSHCommandWidget() + client = object() + widget.ssh_client = client + widget._cleanup = lambda: None + + widget._on_connect_failed(client, exception_tags.host_key_rejected_error.format(hostname="example.org")) + + assert "已拒絕 example.org 的主機金鑰。" in widget.terminal.toPlainText() + widget.ssh_client = None + widget.close() + + def test_a_host_key_the_user_declined_in_the_file_tree(self, app, chinese, monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod + + shown: list = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args[2])) + tree = tree_mod.SSHFileTreeManager() + + tree._on_connect_failed(exception_tags.host_key_rejected_error.format(hostname="example.org")) + + assert len(shown) == 1 and "已拒絕 example.org 的主機金鑰。" in shown[0] + tree.close() + tree.deleteLater() + + +def test_no_tool_tab_shows_a_reason_as_raised(): + # The HAR tab's load error was missed once: its format( call ran over two lines + import pathlib + import re + + import pybreeze + + tabs = pathlib.Path(pybreeze.__file__).parent / "pybreeze_ui" / "tools_gui" + raw = re.compile(r"error\s*=\s*str\(\s*(?:error|err|exc|e)\s*\)") + offenders = [path.name for path in tabs.glob("*.py") if raw.search(path.read_text(encoding="utf-8"))] + assert not offenders + + +class TestADiagramFile: + def test_that_is_not_a_diagram(self, app, chinese, monkeypatch, tmp_path): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + target = tmp_path / "list.diagram.json" + target.write_text("[]", encoding="utf-8") + shown: list = [] + monkeypatch.setattr(diagram_editor_widget.QMessageBox, "warning", lambda *args: shown.append(args[2])) + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(target), ""))) + editor = DiagramEditorWidget() + + editor._open_diagram() + + assert len(shown) == 1 and "架構圖檔案應該是一個物件,而不是 list" in shown[0] + editor.deleteLater() diff --git a/test/test_utils/test_exact_text.py b/test/test_utils/test_exact_text.py new file mode 100644 index 00000000..980d1ad9 --- /dev/null +++ b/test/test_utils/test_exact_text.py @@ -0,0 +1,62 @@ +"""The tools work on the text as it was entered, not on Qt's display copy of it.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import hashlib + +import pytest +from PySide6.QtWidgets import QApplication, QPlainTextEdit, QTextEdit + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.exact_text import exact_text + +# A non-breaking space, and a line separator inside a line +_ENTERED = "price: 100\nnext
line" + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.mark.parametrize("editor", [QTextEdit, QPlainTextEdit]) +def test_every_character_comes_back(app, editor): + edit = editor() + edit.setPlainText(_ENTERED) + + # toPlainText() gives "price: 100\nnext\nline" + assert exact_text(edit) == _ENTERED + + +def test_the_hash_is_of_the_text_entered(app): + from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI + + tool = HashGUI() + tool.input_edit.setPlainText("a b") + + tool.compute() + + # It showed the digest of "a b". + assert hashlib.sha256("a b".encode()).hexdigest() in tool.output_edit.toPlainText() + tool.deleteLater() + + +def test_the_diff_sees_a_non_breaking_space(app): + from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI + + tool = DiffGUI() + tool.left_edit.setPlainText("price: 100") + tool.right_edit.setPlainText("price: 100") + + tool.compare() + tool._compare_thread.wait(10000) + QApplication.processEvents() + + # It called the two identical. + assert tool.output_edit.toPlainText() + tool.deleteLater() diff --git a/test/test_utils/test_exception_tags.py b/test/test_utils/test_exception_tags.py index 58383d7d..28f662ce 100644 --- a/test/test_utils/test_exception_tags.py +++ b/test/test_utils/test_exception_tags.py @@ -1,44 +1,16 @@ from pybreeze.utils.exception.exception_tags import ( - add_command_type_exception_tag, - add_command_not_allow_package_exception_tag, send_html_exception_tag, - auto_control_process_executor_exception_tag, - api_testka_process_executor_exception_tag, - web_runner_process_executor_exception_tag, - load_density_process_executor_exception_tag, - not_install_exception, - wrong_test_data_format_exception_tag, - exec_error, - file_not_fond_error, - compiler_not_found_error, - not_install_package_error, cant_reformat_json_error, wrong_json_data_error, - cant_read_xml_error, - xml_type_error, ) class TestExceptionTags: def test_all_tags_are_strings(self): tags = [ - add_command_type_exception_tag, - add_command_not_allow_package_exception_tag, send_html_exception_tag, - auto_control_process_executor_exception_tag, - api_testka_process_executor_exception_tag, - web_runner_process_executor_exception_tag, - load_density_process_executor_exception_tag, - not_install_exception, - wrong_test_data_format_exception_tag, - exec_error, - file_not_fond_error, - compiler_not_found_error, - not_install_package_error, cant_reformat_json_error, wrong_json_data_error, - cant_read_xml_error, - xml_type_error, ] for tag in tags: assert isinstance(tag, str) @@ -47,9 +19,3 @@ def test_all_tags_are_strings(self): def test_send_html_tag_has_instructions(self): assert "je_mail_thunder" in send_html_exception_tag assert "default_name.html" in send_html_exception_tag - - def test_process_executor_tags_mention_tools(self): - assert "AutoControl" in auto_control_process_executor_exception_tag - assert "APITestka" in api_testka_process_executor_exception_tag - assert "WebRunner" in web_runner_process_executor_exception_tag - assert "LoadDensity" in load_density_process_executor_exception_tag diff --git a/test/test_utils/test_file_tree_context_menu.py b/test/test_utils/test_file_tree_context_menu.py index 69de26ec..5de4c68a 100644 --- a/test/test_utils/test_file_tree_context_menu.py +++ b/test/test_utils/test_file_tree_context_menu.py @@ -7,11 +7,12 @@ from __future__ import annotations import os +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import QPoint, Qt +from PySide6.QtCore import QFileSystemWatcher, QPoint, Qt from PySide6.QtWidgets import ( QApplication, QFileSystemModel, QMessageBox, QTabWidget, QTreeView, QWidget ) @@ -20,7 +21,7 @@ from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as ctx from pybreeze.pybreeze_ui.editor_main.file_tree_context_menu import ( _action_copy_path, _action_delete, _action_new_file, _action_new_folder, - _action_rename, _attach_context_menu, _find_editor_for_file, _get_tree_root_path, + _action_rename, _attach_context_menu, _editors_under, _get_tree_root_path, _perform_file_op, _resolve_parent_dir, setup_file_tree_context_menu ) @@ -69,6 +70,23 @@ def warnings(monkeypatch): return shown +class FakeCodeEdit: + """The editing area, recording what a rename reloads.""" + + def __init__(self, path: str) -> None: + self.current_file = path + self.reloaded: list[str] = [] + + def reset_highlighter(self) -> None: + self.reloaded.append("highlighter") + + def load_git_baseline(self) -> None: + self.reloaded.append("git baseline") + + def start_language_server(self) -> None: + self.reloaded.append("language server") + + class FakeEditor(QWidget): """Stands in for an EditorWidget holding one open file. @@ -79,22 +97,34 @@ class FakeEditor(QWidget): def __init__(self, path: str) -> None: super().__init__() self.current_file = path - self.code_edit = type("Edit", (), {"current_file": path})() + self.code_edit = FakeCodeEdit(path) + # What JEditor's EditorWidget watches its file with + self._file_watcher = QFileSystemWatcher([path], self) + self._ignore_next_change = False self.renamed = False self.closed = False + self.code_save_thread = None + + _is_modified = False def rename_self_tab(self) -> None: + # As JEditor's: it clears the unsaved mark self.renamed = True + self._is_modified = False + + def _on_text_changed(self) -> None: + self._is_modified = True def close(self) -> bool: self.closed = True return super().close() -class FakeWindow: - """A main window with just the tab widget the actions reach for.""" +class FakeWindow(QWidget): + """A main window with just the tab widget the actions reach for; docked editors are its children.""" def __init__(self) -> None: + super().__init__() self.tab_widget = QTabWidget() @@ -212,18 +242,40 @@ def test_renaming_onto_an_existing_file_is_refused( assert occupied.read_text(encoding="utf-8") == "keep me" assert warnings + def test_a_change_of_case_is_a_rename(self, tree, tmp_path, monkeypatch, warnings): + # On Windows "Main.py" already "exists" -- as the very file being renamed. + original = tmp_path / "main.py" + original.write_text("print(1)", encoding="utf-8") + answer(monkeypatch, "Main.py") + + _action_rename(tree, FakeWindow(), original) + + assert warnings == [] + assert [child.name for child in tmp_path.iterdir()] == ["Main.py"] + assert (tmp_path / "Main.py").read_text(encoding="utf-8") == "print(1)" + def test_an_open_tab_follows_the_rename(self, tree, tmp_path, monkeypatch): original = tmp_path / "open.py" original.touch() window = FakeWindow() editor = FakeEditor(str(original)) + editor.code_save_thread = None + monkeypatch.setattr( + ctx, "_editors_under", lambda _w, _p: [(editor, original)]) + # The real one starts JEditor's save thread; the stand-in only records + # where the tab now points, the way it does. monkeypatch.setattr( - ctx, "_find_editor_for_file", lambda _w, _p: editor) + ctx, "init_new_auto_save_thread", + lambda file_path, widget: setattr(widget, "current_file", file_path)) answer(monkeypatch, "renamed.py") _action_rename(tree, window, original) assert editor.current_file == str(tmp_path / "renamed.py") assert editor.code_edit.current_file == str(tmp_path / "renamed.py") assert editor.renamed + # As when JEditor opens a file: it watched the old name, and kept the + # old name's highlighter, git baseline and language server + assert [Path(one) for one in editor._file_watcher.files()] == [tmp_path / "renamed.py"] + assert editor.code_edit.reloaded == ["highlighter", "git baseline", "language server"] class TestDeleting: @@ -259,13 +311,68 @@ def test_an_open_tab_is_closed_with_the_file(self, tree, tmp_path, monkeypatch): window = FakeWindow() editor = FakeEditor(str(target)) window.tab_widget.addTab(editor, "open.py") - monkeypatch.setattr(ctx, "_find_editor_for_file", lambda _w, _p: editor) + monkeypatch.setattr(ctx, "_editors_under", lambda _w, _p: [(editor, target)]) confirm(monkeypatch, yes=True) _action_delete(tree, window, target) assert editor.closed assert window.tab_widget.count() == 0 assert not target.exists() + def test_every_tab_inside_a_deleted_folder_is_closed(self, tree, tmp_path, monkeypatch): + folder = tmp_path / "pkg" + folder.mkdir() + inside = [folder / "a.py", folder / "b.py"] + for file in inside: + file.touch() + window = FakeWindow() + editors = [FakeEditor(str(file)) for file in inside] + outside = FakeEditor(str(tmp_path / "other.py")) + for editor in (*editors, outside): + window.tab_widget.addTab(editor, "tab") + asked = [] + + def under(_window, path): + asked.append(path) + return list(zip(editors, inside)) + + monkeypatch.setattr(ctx, "_editors_under", under) + confirm(monkeypatch, yes=True) + _action_delete(tree, window, folder) + + assert asked == [folder] + assert all(editor.closed for editor in editors) + assert not outside.closed + assert window.tab_widget.count() == 1 + assert not folder.exists() + + def test_a_delete_that_fails_keeps_the_tab_open(self, tree, tmp_path, monkeypatch): + # The tabs used to close before the delete ran, so a locked file stayed + # on disk while its tab, and any unsaved edits in it, were gone. + target = tmp_path / "locked.py" + target.touch() + window = FakeWindow() + editor = FakeEditor(str(target)) + window.tab_widget.addTab(editor, "locked.py") + monkeypatch.setattr(ctx, "_editors_under", lambda _w, _p: [(editor, target)]) + restarted: list = [] + monkeypatch.setattr( + ctx, "init_new_auto_save_thread", + lambda file_path, widget: restarted.append(file_path)) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: None)) + + def refuse(self, *args, **kwargs): + raise PermissionError(13, "The process cannot access the file") + + monkeypatch.setattr(Path, "unlink", refuse) + confirm(monkeypatch, yes=True) + _action_delete(tree, window, target) + + assert target.exists() + assert not editor.closed + assert window.tab_widget.count() == 1 + # Its auto-save, stopped for the delete, runs again. + assert restarted == [str(target)] + class TestCopyingThePath: def test_the_absolute_path_reaches_the_clipboard(self, tree, tmp_path): @@ -293,9 +400,9 @@ def test_nothing_selected_leaves_the_clipboard_alone(self, tree): assert QApplication.clipboard().text() == "untouched" -class TestFindingTheOpenEditor: +class TestFindingTheOpenEditors: def test_a_window_with_no_editor_tabs_finds_nothing(self, app, tmp_path): - assert _find_editor_for_file(FakeWindow(), tmp_path / "any.py") is None + assert _editors_under(FakeWindow(), tmp_path / "any.py") == [] class TestAttachingTheMenu: @@ -326,3 +433,132 @@ def test_setup_leaves_later_tabs_working(self, app): assert index == 0 assert window.tab_widget.count() == 1 placeholder.deleteLater() + + +class TestANameStaysInItsFolder: + """A drive, a root, '..' or ':' put the file elsewhere: /tmp/notes.py became C:\\tmp\\notes.py.""" + + @pytest.mark.parametrize( + "name", ["/tmp/notes.py", "C:\\x.py", "C:x.py", "..\\up.py", "a/../../up.py", "notes.py:stream"]) + def test_a_new_file_outside_the_folder_is_refused(self, tree, tmp_path, monkeypatch, warnings, name): + folder = tmp_path / "project" + folder.mkdir() + answer(monkeypatch, name) + + _action_new_file(tree, folder) + + assert warnings + assert list(folder.iterdir()) == [] + assert sorted(item.name for item in tmp_path.iterdir()) == ["project"] + + def test_a_new_file_in_a_subfolder_is_still_allowed(self, tree, tmp_path, monkeypatch): + answer(monkeypatch, "pkg/module.py") + + _action_new_file(tree, None) + + assert (tmp_path / "pkg" / "module.py").is_file() + + @pytest.mark.parametrize("name", ["/a.py", "sub/a.py", "..\\a.py"]) + def test_a_rename_is_one_name(self, tree, tmp_path, monkeypatch, warnings, name): + original = tmp_path / "a.py" + original.write_text("x", encoding="utf-8") + answer(monkeypatch, name) + + _action_rename(tree, FakeWindow(), original) + + assert warnings + assert original.is_file() + + +class TestDeletingAFolder: + def test_read_only_files_go_too(self, tmp_path): + # rmtree stopped at the first one: git makes its objects read-only, and + # a cloned project was left half deleted with a broken repository + import stat + + folder = tmp_path / "project" + (folder / ".git" / "objects" / "ab").mkdir(parents=True) + (folder / ".git" / "HEAD").write_text("ref", encoding="utf-8") + locked = folder / ".git" / "objects" / "ab" / "cdef" + locked.write_bytes(b"blob") + os.chmod(locked, stat.S_IREAD) + + ctx.remove_folder(folder) + + assert not folder.exists() + + @pytest.mark.skipif(os.name != "nt", reason="junctions are Windows'") + def test_a_junction_is_removed_and_what_it_points_to_is_kept(self, tree, tmp_path, monkeypatch): + import subprocess + + target = tmp_path / "real" + target.mkdir() + (target / "keep.txt").write_text("keep", encoding="utf-8") + link = tmp_path / "link" + subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)], + check=True, capture_output=True, timeout=30) + confirm(monkeypatch, yes=True) + + _action_delete(tree, FakeWindow(), link) + + assert not link.exists() and not os.path.lexists(link) + assert (target / "keep.txt").read_text(encoding="utf-8") == "keep" + + +def test_a_rename_keeps_the_unsaved_mark_of_an_edited_tab(tree, tmp_path, monkeypatch): + # rename_self_tab cleared it while nothing had been saved: closing the tab + # before the new auto-save wrote lost the edits without asking + original = tmp_path / "a.py" + original.write_text("x", encoding="utf-8") + editor = FakeEditor(str(original)) + editor._is_modified = True + monkeypatch.setattr(ctx, "_editors_under", lambda _w, _p: [(editor, original)]) + monkeypatch.setattr( + ctx, "init_new_auto_save_thread", + lambda file_path, widget: setattr(widget, "current_file", file_path)) + answer(monkeypatch, "b.py") + + _action_rename(tree, FakeWindow(), original) + + assert (tmp_path / "b.py").is_file() + assert editor.renamed and editor._is_modified + + + +class TestRevealing: + """A file was never shown selected: its folder opened, and the user had to find it.""" + + @pytest.mark.parametrize(("platform", "flags"), [("win32", ["/select,"]), ("darwin", ["-R"])]) + def test_a_file_is_shown_selected(self, tmp_path, platform, flags): + target = tmp_path / "a.py" + target.write_text("", encoding="utf-8") + + command = ctx.reveal_command(target, platform) + + assert command[1:] == [*flags, str(target)] + + @pytest.mark.parametrize("platform", ["win32", "darwin", "linux"]) + def test_a_folder_is_opened(self, tmp_path, platform): + assert ctx.reveal_command(tmp_path, platform)[1:] == [str(tmp_path)] + + def test_elsewhere_a_files_folder_is_opened(self, tmp_path): + target = tmp_path / "a.py" + target.write_text("", encoding="utf-8") + + assert ctx.reveal_command(target, "linux") == ["xdg-open", str(tmp_path)] + + def test_explorer_is_the_systems(self, tmp_path, monkeypatch): + monkeypatch.setenv("SystemRoot", str(tmp_path)) + + assert ctx.reveal_command(tmp_path, "win32")[0] == str(tmp_path / "explorer.exe") + + def test_a_missing_file_manager_is_shown_not_raised(self, tree, tmp_path, monkeypatch, warnings): + # No xdg-open: FileNotFoundError left the slot as a traceback + def missing(_command): + raise FileNotFoundError(2, "No such file or directory", "xdg-open") + + monkeypatch.setattr(ctx.subprocess, "Popen", missing) + + ctx._action_reveal_in_explorer(tree, tmp_path) + + assert len(warnings) == 1 and "xdg-open" in warnings[0] diff --git a/test/test_utils/test_file_tree_rename.py b/test/test_utils/test_file_tree_rename.py new file mode 100644 index 00000000..d1be249a --- /dev/null +++ b/test/test_utils/test_file_tree_rename.py @@ -0,0 +1,127 @@ +"""Renaming an open file, or its folder, from the file tree. + +The open tab and its auto-save have to follow the file. Left behind, JEditor's +auto-save wrote the buffer back to the old name -- recreating the file the user +had just renamed away -- and never saved the new one again. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_RENAME = """ +import time +from pathlib import Path +from PySide6.QtWidgets import QApplication, QTreeView +from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as menu + +here = Path.cwd() +folder = here / "project" +folder.mkdir() +original = folder / "a.py" +original.write_text("x = 1\\n", encoding="utf-8") +window.go_to_new_tab(original) +editor = window.tab_widget.currentWidget() + +RENAME_WHAT, NEW_NAME = {what!r}, {new_name!r} +renamed = original if RENAME_WHAT == "file" else folder +menu.QInputDialog.getText = staticmethod(lambda *args, **kwargs: (NEW_NAME, True)) +menu._action_rename(QTreeView(), window, renamed) +expected = (folder / NEW_NAME) if RENAME_WHAT == "file" else (here / NEW_NAME / "a.py") + +editor.code_edit.setPlainText("x = 2\\n") +deadline = time.monotonic() + 15 +while time.monotonic() < deadline: + QApplication.processEvents() + if expected.is_file() and "x = 2" in expected.read_text(encoding="utf-8"): + break + time.sleep(0.1) + +result = {{ + "tab_file": str(Path(editor.current_file)) == str(expected), + "saved_to_new": expected.is_file() and "x = 2" in expected.read_text(encoding="utf-8"), + "old_came_back": original.exists(), +}} +""" + + +def _rename(tmp_path, what: str, new_name: str) -> dict: + return run_started_window(tmp_path, _RENAME.format(what=what, new_name=new_name)) + + +def test_a_renamed_file_is_saved_under_its_new_name(tmp_path): + seen = _rename(tmp_path, "file", "b.py") + + assert seen["tab_file"] + assert seen["saved_to_new"], "the new name never got the edit" + assert not seen["old_came_back"], "auto-save recreated the old name" + + +def test_a_file_in_a_renamed_folder_follows_it(tmp_path): + seen = _rename(tmp_path, "folder", "renamed_project") + + assert seen["tab_file"] + assert seen["saved_to_new"], "the file under the new folder never got the edit" + assert not seen["old_came_back"], "auto-save recreated the old folder" + + +_RENAME_WITH_A_DOCK_AND_A_WATCH = """ +from pathlib import Path +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QTreeView +from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock +from je_editor.pyside_ui.main_ui.editor.editor_widget_dock import FullEditorWidget +from pybreeze.pybreeze_ui.editor_main import file_tree_context_menu as menu + +here = Path.cwd() +original = here / "a.txt" +original.write_text("original\\n", encoding="utf-8") +window.go_to_new_tab(original) +editor = window.tab_widget.currentWidget() +dock = DestroyDock() +docked = FullEditorWidget(str(original)) +dock.setWidget(docked) +window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) +other = here / "other.txt" +other.write_text("other", encoding="utf-8") +other_dock = DestroyDock() +docked_elsewhere = FullEditorWidget(str(other)) +other_dock.setWidget(docked_elsewhere) +window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, other_dock) +docked.code_edit.setPlainText("edited in the dock\\n") +# setPlainText leaves the document unmodified; a real edit marks it, and the +# dock now writes on close only when modified (je_editor >= 1.0.27) +docked.code_edit.document().setModified(True) + +menu.QInputDialog.getText = staticmethod(lambda *args, **kwargs: ("b.py", True)) +menu._action_rename(QTreeView(), window, original) +renamed = here / "b.py" + +watched = [str(Path(one)) for one in editor._file_watcher.files()] +docked_file = docked.current_file +docked.close() + +result = { + "watched": watched == [str(renamed)], + "watched_raw": watched, "renamed_raw": str(renamed), + "ignoring": editor._ignore_next_change, + "docked_file": str(Path(docked_file)) == str(renamed), + "dock_saved": renamed.read_text(encoding="utf-8") == "edited in the dock\\n", + "old_came_back": original.exists(), + "other_left_alone": docked_elsewhere.current_file == str(other), +} +""" + + +def test_a_rename_moves_the_watch_and_the_docked_editor_too(tmp_path): + seen = run_started_window(tmp_path, _RENAME_WITH_A_DOCK_AND_A_WATCH) + + # The watch stayed on the old name: a change made outside the IDE raised + # no question, and the tab's auto-save wrote over it + assert seen["watched"], (seen["watched_raw"], seen["renamed_raw"]) + assert not seen["ignoring"] + # The docked editor stayed on the old name and, closing, wrote nothing + assert seen["docked_file"] + assert seen["dock_saved"], "the docked editor's edits were lost" + assert not seen["old_came_back"] + assert seen["other_left_alone"] + diff --git a/test/test_utils/test_fuzz_pure_logic.py b/test/test_utils/test_fuzz_pure_logic.py index 22e87d78..df24b18d 100644 --- a/test/test_utils/test_fuzz_pure_logic.py +++ b/test/test_utils/test_fuzz_pure_logic.py @@ -6,11 +6,8 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import ( - format_size, - natural_key, - remote_join, -) +from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import natural_key, remote_join +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import format_size from pybreeze.pybreeze_ui.diagram_editor.diagram_mermaid_parser import parse_mermaid from pybreeze.pybreeze_ui.diagram_editor.diagram_net_utils import ( _is_text_content_type, diff --git a/test/test_utils/test_gui_thread_gc.py b/test/test_utils/test_gui_thread_gc.py new file mode 100644 index 00000000..bef3874e --- /dev/null +++ b/test/test_utils/test_gui_thread_gc.py @@ -0,0 +1,99 @@ +"""Cyclic garbage is collected on the GUI thread, never on a worker.""" +from __future__ import annotations + +import gc +import os +import threading + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QObject + +from pybreeze.pybreeze_ui import gui_thread_gc +from pybreeze.pybreeze_ui.gui_thread_gc import GuiThreadGarbageCollector + + +@pytest.fixture() +def collector(): + was_enabled = gc.isenabled() + made = GuiThreadGarbageCollector() + yield made + made.stop() + if not was_enabled: + gc.disable() + + +class _Cyclic(QObject): + """A Python-owned QObject that only the cycle collector can free.""" + + def __init__(self) -> None: + super().__init__() + self.me = self + + +def test_automatic_collection_is_off_until_stopped(collector): + assert gc.isenabled() is False + + collector.stop() + + assert gc.isenabled() is True + + +def test_a_worker_that_allocates_frees_no_qt_object(collector): + # A worker allocating past the threshold used to collect a Qt object made + # on the GUI thread, and run its destructor there + import weakref + + freed_in: list = [] + cyclic = _Cyclic() + # A connection to its destroyed signal would keep it alive; a weak + # reference's callback runs in whichever thread frees it + watch = weakref.ref(cyclic, lambda _ref: freed_in.append(threading.current_thread().name)) + del cyclic + + worker = threading.Thread(target=lambda: [{} for _ in range(200_000)], name="worker") + worker.start() + worker.join() + assert freed_in == [] + + gc.collect() + assert freed_in == [threading.main_thread().name] + assert watch() is None + + +@pytest.mark.parametrize(("counts", "collected"), [ + ((700, 0, 0), []), + ((701, 0, 0), [0]), + ((701, 11, 3), [0, 1]), + ((701, 11, 11), [0, 1, 2]), +]) +def test_a_generation_is_collected_past_the_interpreters_threshold(collector, monkeypatch, counts, collected): + done: list = [] + monkeypatch.setattr(collector, "_thresholds", (700, 10, 10)) + monkeypatch.setattr(gui_thread_gc.gc, "get_count", lambda: counts) + monkeypatch.setattr(gui_thread_gc.gc, "collect", done.append) + + collector.check() + + assert done == collected + + +def test_a_threshold_of_zero_is_never_reached(collector, monkeypatch): + # Python 3.14 leaves the third threshold at 0 + done: list = [] + monkeypatch.setattr(collector, "_thresholds", (2000, 10, 0)) + monkeypatch.setattr(gui_thread_gc.gc, "get_count", lambda: (2001, 11, 5)) + monkeypatch.setattr(gui_thread_gc.gc, "collect", done.append) + + collector.check() + + assert done == [0, 1] + + +def test_the_ide_switches_collection_to_its_gui_thread(): + import inspect + + from pybreeze.pybreeze_ui.editor_main import main_ui + + assert "collect_garbage_on_gui_thread(new_ide)" in inspect.getsource(main_ui.start_editor) diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py index 70b47195..f5f89d24 100644 --- a/test/test_utils/test_har_import.py +++ b/test/test_utils/test_har_import.py @@ -96,6 +96,13 @@ def test_no_usable_entries(self): with pytest.raises(HarParseException): parse_har(_har()) + def test_an_entry_with_a_malformed_url_is_skipped(self): + # Listing it raised ValueError out of the tab, which kept showing the + # previous file's requests, and "Generate all" generated those. + entries = parse_har(_har(_entry(url="http://[::1/api"), _entry())) + + assert [entry.request.url for entry in entries] == ["https://api.example.com/v1/items"] + class TestHeadersAndCookies: def test_headers_are_collected(self): @@ -134,6 +141,20 @@ def test_cookie_header_is_dropped_when_cookies_were_recorded(self): assert entry.request.cookies == {"sid": "abc"} assert "Cookie" not in entry.request.headers + def test_cookies_sharing_a_name_all_go_as_the_header(self): + # Two paths, two sid cookies: a dict would keep only the last + entry = parse_har(_har(_entry( + headers=[{"name": "cookie", "value": "sid=1; sid=2"}], + cookies=[{"name": "sid", "value": "1"}, {"name": "sid", "value": "2"}])))[0] + assert entry.request.cookies == {} + assert entry.request.headers == {"cookie": "sid=1; sid=2"} + + def test_cookies_sharing_a_name_without_a_header_make_one(self): + entry = parse_har(_har(_entry( + cookies=[{"name": "sid", "value": "1"}, {"name": "sid", "value": "2"}])))[0] + assert entry.request.cookies == {} + assert entry.request.headers == {"Cookie": "sid=1; sid=2"} + def test_cookie_header_is_kept_when_no_cookies_were_recorded(self): entry = parse_har(_har(_entry(headers=[{"name": "Cookie", "value": "sid=abc"}])))[0] assert entry.request.headers["Cookie"] == "sid=abc" @@ -179,7 +200,17 @@ def test_multipart_params_become_form_fields(self): {"name": "note", "value": "hi"}, {"name": "file", "value": "", "fileName": "a.png"}, ]})))[0] - assert entry.request.form_fields == ["note=hi", "file=@a.png"] + assert entry.request.form_strings == ["note=hi"] + assert entry.request.form_fields == ["file=@a.png"] + + def test_a_text_field_starting_with_at_is_not_a_file(self): + from pybreeze.utils.curl_import.request_body import form_parts + + entry = parse_har(_har(_entry(method="POST", post_data={ + "mimeType": "multipart/form-data; boundary=x", + "params": [{"name": "handle", "value": "@alice"}]})))[0] + + assert form_parts(entry.request) == ({"handle": "@alice"}, {}) def test_no_post_data_leaves_no_body(self): assert not parse_har(_har(_entry()))[0].request.has_body @@ -236,6 +267,15 @@ def test_repeated_endpoint_is_numbered(self): _entry(url="https://x/api/items"), _entry(url="https://x/api/items")))] assert unique_test_names(requests) == ["test_get_api_items", "test_get_api_items_2"] + @pytest.mark.parametrize("paths", [("a", "a", "a/2"), ("a/2", "a", "a"), ("a", "a/2", "a", "a", "a/3")]) + def test_a_number_never_takes_another_requests_own_name(self, paths): + requests = [e.request for e in parse_har(_har(*[_entry(url=f"https://x/{p}") for p in paths]))] + + names = unique_test_names(requests) + + assert len(set(names)) == len(names) + assert "test_get_a_2" in names + class TestGenerateHarScript: def _requests(self, *urls: str): @@ -289,3 +329,86 @@ def test_loaddensity_script_keeps_every_request(self): def test_unknown_target_falls_back_to_requests(self): code = generate_har_script("nonsense", self._requests("https://x/one", "https://x/two")) assert "import requests" in code + + +class TestQueryValuesFromTheUrl: + """A value in the recorded URL is percent-encoded; params hold it decoded, once.""" + + def test_an_encoded_value_is_decoded(self): + entry = parse_har(_har(_entry(url="https://x/api?q=hello+world&tag=a%2Bb")))[0] + + assert entry.request.params == {"q": "hello world", "tag": "a+b"} + + def test_the_rebuilt_url_is_encoded_once(self): + entry = parse_har(_har(_entry(url="https://x/api?q=hello+world")))[0] + + assert entry.request.full_url == "https://x/api?q=hello+world" + + def test_a_query_that_would_not_come_back_as_written_stays_as_recorded(self): + # Decoded and encoded again, %20 became + and a bare key gained = + entry = parse_har(_har(_entry(url="https://x/api?q=hello%20world&flag")))[0] + + assert entry.request.params == {} + assert entry.request.full_url == "https://x/api?q=hello%20world&flag" + + +class TestWhatReachesTheGeneratedCode: + """A recording's method and URL end up in code; nothing in them may become code.""" + + def test_a_method_that_is_not_a_token_is_refused(self): + with pytest.raises(HarParseException, match="not an HTTP method"): + parse_har(_har(_entry(method="GET():\n __import__('os').system('calc')\ndef t"))) + + @pytest.mark.parametrize("bad", [ + {"url": "https://x/a?q=\ud800"}, + {"url": "https://x/a", "query": [{"name": "q", "value": "\ud800"}]}, + {"url": "https://x/a", "headers": [{"name": "X", "value": "\udfff"}]}, + ]) + def test_an_entry_with_half_a_character_is_skipped_and_the_rest_load(self, bad): + # JSON's "\ud800" escape: encoding it raised UnicodeEncodeError out of the tab + entries = parse_har(_har(_entry(**bad), _entry(url="https://x/b"))) + + assert [entry.request.url for entry in entries] == ["https://x/b"] + + def test_an_entry_with_a_bad_method_is_skipped_and_the_rest_load(self): + entries = parse_har(_har(_entry(url="https://x/a", method=""), _entry(url="https://x/b"))) + + assert [entry.request.url for entry in entries] == ["https://x/b"] + + def test_a_line_break_in_a_url_stays_inside_the_comment(self): + import ast + + url = "https://x/a\nimport os; os.system('calc') #" + requests = [e.request for e in parse_har(_har(_entry(url=url), _entry()))] + + code = generate_har_script("requests", requests) + + imports = [node for node in ast.parse(code).body if isinstance(node, ast.Import)] + assert [alias.name for node in imports for alias in node.names] == ["requests"] + assert "\x0a" in code + + +class TestRepeatedQueryKeys: + def test_every_value_in_the_url_is_kept_once(self): + entry = parse_har(_har(_entry( + url="https://api.example.com/v1/items?id=1&id=2", + query=[{"name": "id", "value": "1"}, {"name": "id", "value": "2"}])))[0] + + assert entry.request.params == {"id": ["1", "2"]} + + def test_a_recorded_name_the_url_lacks_keeps_all_its_values(self): + entry = parse_har(_har(_entry( + url="https://api.example.com/v1/items?q=a", + query=[{"name": "q", "value": "a"}, + {"name": "tag", "value": "x"}, {"name": "tag", "value": "y"}])))[0] + + assert entry.request.params == {"q": "a", "tag": ["x", "y"]} + + +def test_json_nested_too_deep_is_reported_not_raised_out_of_the_tab(): + from pybreeze.utils.exception.exceptions import HarParseException + from pybreeze.utils.har_import.har_parser import parse_har + + # json.loads raises RecursionError, which the tab did not catch + with pytest.raises(HarParseException): + parse_har("[" * 100000) diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py index d5d1d5b6..2ad9d2c2 100644 --- a/test/test_utils/test_har_import_gui.py +++ b/test/test_utils/test_har_import_gui.py @@ -155,6 +155,16 @@ def test_open_file_loads_the_chosen_export(self, widget, tmp_path): assert result == str(path) assert widget.entry_list.count() == 2 + def test_an_export_with_a_byte_order_mark_loads(self, widget, tmp_path): + path = tmp_path / "session.har" + path.write_text(_HAR, encoding="utf-8-sig") # starts with a BOM + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(path), "HAR export (*.har *.json)"), + ): + assert widget.open_file() == str(path) + assert widget.entry_list.count() == 2 + def test_cancelled_dialog_changes_nothing(self, widget): with patch( "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", @@ -171,3 +181,98 @@ def test_unreadable_file_reports_an_error(self, widget, tmp_path): ): assert widget.open_file() is None assert widget.output_edit.toPlainText() != "" + + +class TestReadingAFileThatIsNotUsable: + def test_a_file_that_is_not_utf8_is_reported_not_raised(self, widget, tmp_path): + exported = tmp_path / "export.har" + exported.write_text('{"log": {"entries": []}}', encoding="utf-16") + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(exported), ""), + ): + assert widget.open_file() is None + assert "UTF-8" in widget.output_edit.toPlainText() + + def test_the_message_does_not_show_the_path(self, widget, tmp_path): + missing = tmp_path / "private-folder-name" / "gone.har" + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(missing), ""), + ): + widget.open_file() + shown = widget.output_edit.toPlainText() + assert shown + assert "private-folder-name" not in shown + + +def test_a_target_that_cannot_carry_an_upload_says_so(widget): + # The APITestka JSON action cannot open a file: it raised out of the slot + har = json.dumps({"log": {"entries": [{"request": { + "method": "POST", "url": "https://api.example.com/upload", "headers": [], + "postData": {"mimeType": "multipart/form-data", + "params": [{"name": "photo", "fileName": "a.jpg"}]}, + }}]}}) + widget.load_text(har) + widget.target_select.setCurrentIndex(widget.target_select.findData("apitestka_action")) + + widget.generate_all() + + assert "choose a Python target" in widget.output_edit.toPlainText() + + +class TestWhatTheOutputBelongsTo: + """Save writes the output: it must be the script of the file and target now shown.""" + + def test_loading_another_file_clears_the_previous_script(self, loaded): + loaded.generate_all() + other = _HAR.replace("api.example.com", "second.example") + + assert loaded.load_text(other) + + assert loaded.output_edit.toPlainText() == "" + assert not loaded.actions._has_output() + + def test_a_file_that_cannot_be_read_unlists_the_previous_one(self, loaded, tmp_path): + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(tmp_path / "gone.har"), ""), + ): + loaded.open_file() + + assert loaded.entry_list.count() == 0 + loaded.generate_all() + assert "/v1/items" not in loaded.output_edit.toPlainText() + assert not loaded.actions._has_output() + + def test_choosing_another_target_generates_again(self, loaded): + loaded.generate_all() + + loaded.target_select.setCurrentIndex(loaded.target_select.findData("apitestka_action")) + + assert len(json.loads(loaded.output_edit.toPlainText())) == 2 + assert loaded.actions.suggested_filename() == "actions.json" + + def test_choosing_a_target_before_generating_generates_nothing(self, loaded): + loaded.target_select.setCurrentIndex(loaded.target_select.findData("pytest")) + + assert loaded.output_edit.toPlainText() == "" + + def test_going_back_from_a_target_that_failed_generates_again(self, widget): + # A recorded upload: the APITestka action list cannot carry it + upload = json.dumps({"log": {"entries": [{ + "request": {"method": "POST", "url": "https://api.example.com/up", "headers": [], + "postData": {"mimeType": "multipart/form-data", + "params": [{"name": "f", "fileName": "a.txt"}]}}, + "response": {"status": 200, "content": {"mimeType": "application/json"}}}]}}) + widget.load_text(upload) + widget.api_only_check.setChecked(False) + widget.generate_all() + requests_script = widget.output_edit.toPlainText() + + widget.target_select.setCurrentIndex(widget.target_select.findData("apitestka_action")) + assert not widget.actions._has_output() + widget.target_select.setCurrentIndex(widget.target_select.findData("requests")) + + assert widget.output_edit.toPlainText() == requests_script + assert widget.actions._has_output() diff --git a/test/test_utils/test_header_analyzer.py b/test/test_utils/test_header_analyzer.py index ddb29534..d6fbd277 100644 --- a/test/test_utils/test_header_analyzer.py +++ b/test/test_utils/test_header_analyzer.py @@ -188,3 +188,47 @@ def test_empty_input_yields_empty_analysis(self): def test_text_without_headers_yields_no_fields(self): assert analyze_headers("just some prose").fields == [] + + +def test_a_max_age_too_long_for_int_is_long_enough(): + assert "hsts_weak_max_age" not in _codes( + "Strict-Transport-Security: max-age=" + "9" * 5000) + + +class TestWhatHeadersActuallySay: + def test_a_quoted_max_age_is_read(self): + # RFC 6797 lets the value be quoted; it read as 0 and was called weak + text = 'Strict-Transport-Security: max-age="31536000"; includeSubDomains' + + assert "hsts_weak_max_age" not in _codes(text) + + def test_a_folded_line_is_part_of_the_value_above(self): + # Dropped, the directive written on it was never checked + text = "Content-Security-Policy: default-src 'self';\n script-src 'unsafe-inline'" + + assert "csp_unsafe_directive" in _codes(text) + (field,) = parse_headers(text) + assert field.value == "default-src 'self'; script-src 'unsafe-inline'" + + def test_a_tab_folds_too(self): + (field,) = parse_headers("X-Long: a\n\tb") + + assert field.value == "a b" + + +class TestAnIndentedBlock: + def test_every_line_is_its_own_header(self): + # Every line after the first read as folded into it, and nothing was checked + from pybreeze.utils.header_tools.header_analyzer import parse_headers + + fields = parse_headers(" Content-Type: text/html\n Server: nginx\n Set-Cookie: sid=1") + + assert [field.name for field in fields] == ["Content-Type", "Server", "Set-Cookie"] + + def test_a_folded_line_inside_it_still_folds(self): + from pybreeze.utils.header_tools.header_analyzer import parse_headers + + fields = parse_headers(" Content-Security-Policy: default-src 'self';\n script-src 'self'\n Server: x") + + assert [field.name for field in fields] == ["Content-Security-Policy", "Server"] + assert fields[0].value == "default-src 'self'; script-src 'self'" diff --git a/test/test_utils/test_header_analyzer_gui.py b/test/test_utils/test_header_analyzer_gui.py index aa8acb17..6b4b9451 100644 --- a/test/test_utils/test_header_analyzer_gui.py +++ b/test/test_utils/test_header_analyzer_gui.py @@ -191,3 +191,12 @@ def test_report_never_falls_back_to_a_code(self, app): for finding in analysis.findings: assert finding.code not in report assert "secret" not in report.split("== Findings ==")[1] + + +def test_an_indented_paste_is_read_line_by_line(widget): + # The tab stripped the first line's indent only, and the rest read as folded + widget.input_edit.setPlainText(" Content-Type: text/html\n Server: nginx\n Set-Cookie: sid=1") + + widget.analyze() + + assert [field.name for field in widget._analysis.fields] == ["Content-Type", "Server", "Set-Cookie"] diff --git a/test/test_utils/test_http_client.py b/test/test_utils/test_http_client.py index 9353ca77..d83a3722 100644 --- a/test/test_utils/test_http_client.py +++ b/test/test_utils/test_http_client.py @@ -1,11 +1,14 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest from pybreeze.utils.network.http_client import ( CONNECT_TIMEOUT, ResponseTooLargeError, read_capped_text, + succeeded, truncate_for_display, ) @@ -24,9 +27,14 @@ def test_shorter_than_typical_read_timeouts(self): class FakeResponse: """Minimal stand-in for a streamed requests.Response.""" - def __init__(self, body: bytes, encoding: str | None = "utf-8", chunk: int = 8): + def __init__(self, body: bytes, encoding: str | None = "utf-8", chunk: int = 8, + content_type: str | None = None): self._body = body self.encoding = encoding + # The charset is read from the header, as a server sends it + if content_type is None and encoding is not None: + content_type = f"text/plain; charset={encoding}" + self.headers = {"Content-Type": content_type} if content_type else {} self._chunk = chunk self.closed = False @@ -74,3 +82,109 @@ def test_long_text_truncated_with_marker(self): assert result.startswith("x" * 100) assert "truncated" in result assert "5000" in result + + +class TestAnEncodingTheServerNames: + def test_an_encoding_python_does_not_know_falls_back(self): + # The charset comes from the response's Content-Type: a server naming + # one Python has never heard of must not raise out of the read. + resp = FakeResponse("héllo".encode("utf-8"), encoding="totally-made-up") + + assert read_capped_text(resp, default_encoding="utf-8") == "héllo" + + +class TestSucceeded: + @pytest.mark.parametrize("status", [200, 201, 204, 299]) + def test_a_2xx_is_an_answer(self, status): + assert succeeded(SimpleNamespace(status_code=status)) + + @pytest.mark.parametrize("status", [199, 301, 302, 304, 400, 404, 500]) + def test_anything_else_is_not(self, status): + # 3xx included, although requests calls it "ok" + assert not succeeded(SimpleNamespace(status_code=status)) + + +class TestWhichCharset: + def test_text_without_a_charset_is_utf8_not_latin1(self): + # requests sets ISO-8859-1 on any text/* without a charset + resp = FakeResponse("程式碼審查 ✓".encode("utf-8"), encoding="ISO-8859-1", content_type="text/plain") + + assert read_capped_text(resp) == "程式碼審查 ✓" + + def test_a_named_charset_is_used(self): + resp = FakeResponse("héllo".encode("latin-1"), encoding="ISO-8859-1", + content_type='text/plain; charset="ISO-8859-1"') + + assert read_capped_text(resp) == "héllo" + + def test_json_without_a_charset_is_utf8(self): + resp = FakeResponse('{"a": "ü"}'.encode("utf-8"), encoding=None, content_type="application/json") + + assert read_capped_text(resp) == '{"a": "ü"}' + + +class TestHowLongAnAnswerMayTake: + def test_a_trickle_is_cut_off_at_the_deadline(self, monkeypatch): + # Each chunk came inside the read timeout, so nothing ever stopped it + import requests + + from pybreeze.utils.network import http_client + + clock = iter(range(0, 10_000, 25)) # every chunk 25 s after the last + monkeypatch.setattr(http_client.time, "monotonic", lambda: next(clock)) + resp = FakeResponse(b"x" * 100, chunk=1) + + with pytest.raises(requests.exceptions.ReadTimeout): + read_capped_text(resp, max_seconds=300) + assert resp.closed + + def test_a_byte_now_and_then_inside_one_chunk_is_cut_off_too(self): + # A server announcing a long body and sending a byte every so often held + # one chunk's read for as long as it liked: each byte restarted the read + # timeout, and the deadline was only looked at between chunks + import socket + import threading + import time + + import requests + + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + stop = threading.Event() + + def trickle() -> None: + connection, _ = listener.accept() + with connection: + connection.recv(65536) + connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n") + while not stop.is_set(): + try: + connection.sendall(b"x") + except OSError: + return + stop.wait(0.2) + + server = threading.Thread(target=trickle, daemon=True) + server.start() + try: + response = requests.get( # noqa: S113 — a loopback server this test started; timeout given + f"http://127.0.0.1:{listener.getsockname()[1]}/", stream=True, timeout=(5, 5)) + started = time.monotonic() + with pytest.raises(requests.exceptions.ReadTimeout): + read_capped_text(response, max_seconds=1) + assert time.monotonic() - started < 4 + finally: + stop.set() + listener.close() + server.join(5) + + def test_a_timely_answer_is_read_whole(self): + assert read_capped_text(FakeResponse(b"x" * 100, chunk=1), max_seconds=300) == "x" * 100 + + def test_the_deadline_reads_as_a_timeout_to_the_user(self): + import requests + + from pybreeze.utils.network.http_client import describe_request_error + + assert "timed out" in describe_request_error(requests.exceptions.ReadTimeout("slow")) diff --git a/test/test_utils/test_http_goes_through_public_connections.py b/test/test_utils/test_http_goes_through_public_connections.py new file mode 100644 index 00000000..4ebfae76 --- /dev/null +++ b/test/test_utils/test_http_goes_through_public_connections.py @@ -0,0 +1,57 @@ +"""Every request the IDE sends goes through the connections that pin the checked address. + +``validate_url`` alone checks one DNS answer and the request connects on +another. ``public_http`` connects to the address it checks as it connects; a +call straight to ``requests`` or ``urlopen`` would skip that. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pybreeze + +_SOURCE = Path(pybreeze.__file__).parent +# Where the pinned session is built +_ALLOWED = {Path("utils/network/public_http.py")} +_REQUESTS_CALLS = {"get", "post", "put", "patch", "delete", "head", "options", "request", "Session", "session"} +_URLLIB_CALLS = {"urlopen"} + + +def _direct_calls(source: Path) -> list[str]: + found: list[str] = [] + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): + if not isinstance(node, ast.Call): + continue + function = node.func + if isinstance(function, ast.Attribute) and isinstance(function.value, ast.Name): + if function.value.id == "requests" and function.attr in _REQUESTS_CALLS: + found.append(f"requests.{function.attr}") + elif function.attr in _URLLIB_CALLS: + found.append(function.attr) + elif isinstance(function, ast.Name) and function.id in _URLLIB_CALLS: + found.append(function.id) + return found + + +def test_no_module_sends_a_request_around_the_pinned_connections(): + offenders = { + str(source.relative_to(_SOURCE)): calls + for source in sorted(_SOURCE.rglob("*.py")) + if source.relative_to(_SOURCE) not in _ALLOWED and (calls := _direct_calls(source)) + } + + assert offenders == {} + + +def test_the_scan_finds_a_direct_call(tmp_path): + sample = tmp_path / "sample.py" + sample.write_text( + "import requests\nfrom urllib.request import urlopen\n" + "requests.post('https://x')\nurlopen('https://x')\n" + "code = 'requests.request(\"GET\", url)'\n", + encoding="utf-8", + ) + + # The string is generated code, not a call + assert _direct_calls(sample) == ["requests.post", "urlopen"] diff --git a/test/test_utils/test_install_menu.py b/test/test_utils/test_install_menu.py new file mode 100644 index 00000000..f60a7762 --- /dev/null +++ b/test/test_utils/test_install_menu.py @@ -0,0 +1,144 @@ +"""Installing packages from the Install menu: one pip, no shell, in a run window.""" +from __future__ import annotations + +import os +import sys + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.install_menu import install_utils +from pybreeze.pybreeze_ui.menu.install_menu.install_utils import install_package + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class Process: + """Records the module run instead of starting it.""" + + def __init__(self) -> None: + self.runs: list = [] + + def start_module_process(self, package, arguments, environment=None) -> None: + self.runs.append((package, list(arguments))) + + +@pytest.fixture() +def processes(monkeypatch) -> list: + started: list = [] + + def build(_window) -> Process: + process = Process() + started.append(process) + return process + + monkeypatch.setattr(install_utils, "build_task_process", build) + return started + + +def _runs(processes) -> list: + return [run for process in processes for run in process.runs] + + +class TestInstallingAPackage: + def test_it_runs_pip_as_a_module(self, app, processes): + install_package("je_web_runner", object()) + + assert _runs(processes) == [("pip", ["install", "-U", "je_web_runner"])] + + def test_a_source_folder_target_is_one_argument(self, app, processes): + install_package(r"D:\R&D\prthinker[runner]", object()) + + assert _runs(processes) == [("pip", ["install", "-U", r"D:\R&D\prthinker[runner]"])] + + +class TestNoShellIsInvolved: + def test_a_folder_name_with_shell_characters_reaches_the_module_unchanged( + self, app, tmp_path): + # Through cmd.exe, "&" ended the command. The executor pip now runs in + # passes argv to the child as it is: run it for real, with a module + # standing in for pip that prints what it was given. + import time + + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + (tmp_path / "echo_argv.py").write_text( + "import sys\nprint(repr(sys.argv[1:]))\n", encoding="utf-8") + target = r"D:\R&D|x^y%PATH%\prthinker[runner]" + + class MainWindow: + python_compiler = sys.executable + encoding = "utf-8" + + def __init__(self) -> None: + self.current_run_code_window: list = [] + + def clear_code_result(self) -> None: + """Nothing to clear in a stand-in.""" + + window = MainWindow() + build_task_process(window).start_module_process( + "echo_argv", ["install", "-U", target], environment={"PYTHONPATH": str(tmp_path)}) + run_window = window.current_run_code_window[0] + deadline = time.monotonic() + 30 + while "Task exit with code" not in run_window.code_result.toPlainText(): + assert time.monotonic() < deadline, "the child did not finish" + app.processEvents() + time.sleep(0.01) + + assert repr(["install", "-U", target]) in run_window.code_result.toPlainText() + + +class TestInstallingTheBuildTools: + def test_one_pip_installs_all_three(self, app, processes): + from pybreeze.pybreeze_ui.menu.install_menu.tools_menu.build_tool_install_menu import ( + install_build_tools, + ) + + install_build_tools(object()) + + assert _runs(processes) == [("pip", ["install", "-U", "setuptools", "build", "wheel"])] + + +class TestInstallingPrthinker: + def test_a_remembered_source_folder_is_installed(self, app, processes, monkeypatch, tmp_path): + from pybreeze.pybreeze_ui.menu.install_menu.automation_menu import ( + build_automation_install_menu as install_menu, + ) + + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "prthinker"\n', encoding="utf-8") + monkeypatch.setattr(install_menu, "load_setting", lambda: {"source_path": str(tmp_path)}) + + install_menu.install_prthinker(object()) + + (run,) = _runs(processes) + assert run[0] == "pip" and run[1][2].startswith(str(tmp_path)) + + def test_a_folder_that_is_not_its_source_is_not_saved(self, app, processes, monkeypatch, tmp_path): + from PySide6.QtWidgets import QFileDialog, QMessageBox + + from pybreeze.pybreeze_ui.menu.install_menu.automation_menu import ( + build_automation_install_menu as install_menu, + ) + + saved: list = [] + monkeypatch.setattr(install_menu, "load_setting", lambda: {"source_path": ""}) + monkeypatch.setattr(install_menu, "save_setting", saved.append) + monkeypatch.setattr( + QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: str(tmp_path))) + monkeypatch.setattr(QMessageBox, "exec", lambda self: 0) + + install_menu.install_prthinker(None) + + # A wrong pick used to be remembered, so every later click ran a pip that failed. + assert saved == [] + assert _runs(processes) == [] diff --git a/test/test_utils/test_jeditor_contract.py b/test/test_utils/test_jeditor_contract.py new file mode 100644 index 00000000..af4d3b97 --- /dev/null +++ b/test/test_utils/test_jeditor_contract.py @@ -0,0 +1,200 @@ +"""What PyBreeze takes from JEditor that je_editor does not export. + +je_editor's ``__all__`` is its promise; the names below sit outside it, in module +paths JEditor is free to move. PyBreeze still needs them, so this file pins each +one down: its import path, and the shape PyBreeze calls it with. When JEditor +moves or reshapes one, this fails here instead of in the IDE at the moment +someone opens that menu. ``architecture.md`` §6 lists the same set; keep the two +in step, and move a name to ``EXPORTED`` once je_editor exports it +(workspace X-17). +""" +from __future__ import annotations + +import importlib +import inspect +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +# (module, name, where PyBreeze uses it) +INTERNAL = [ + ("je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget", "PluginBrowserWidget", + "menu/plugin_menu/build_plugin_menu.py"), + ("je_editor.pyside_ui.main_ui.dock.destroy_dock", "DestroyDock", + "closing.py, editor_main/main_ui.py"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "init_new_auto_save_thread", + "editor_main/file_tree_context_menu.py"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "auto_save_manager_dict", + "editor_main/file_tree_context_menu.py"), + ("je_editor.pyside_ui.code.auto_save.auto_save_manager", "file_is_open_manager_dict", + "editor_main/file_tree_context_menu.py"), + ("je_editor.pyside_ui.main_ui.editor.editor_widget_dock", "FullEditorWidget", + "editor_main/file_tree_context_menu.py"), + ("je_editor.utils.venv_check.check_venv", "check_and_choose_venv", + "extend/process_executor/python_task_process_manager.py"), + ("je_editor.pyside_ui.dialog.file_dialog.save_file_dialog", "choose_file_get_save_file_path", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.utils.file.save.save_file", "write_file_with_encoding", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.utils.encodings.text_codec", "DEFAULT_ENCODING", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.utils.encodings.text_codec", "LINE_ENDING_LF", + "menu/plugin_menu/build_run_with_menu.py"), + ("je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file", "actually_color_dict", + "show_code_window/code_window.py, auto_control_menu/build_autocontrol_menu.py"), +] + +# Names PyBreeze imports from je_editor's top level, i.e. from its __all__. +EXPORTED = [ + "EditorMain", "EditorWidget", "MainBrowserWidget", "ShellManager", "JEditorExecException", + "language_wrapper", "english_word_dict", "traditional_chinese_word_dict", "jeditor_logger", + "load_external_plugins", "register_programming_language", "register_natural_language", + "get_all_plugin_run_configs", "get_all_plugin_metadata", +] + + +def _internal(module: str, name: str): + return getattr(importlib.import_module(module), name) + + +def _parameters(function) -> list[str]: + return [name for name in inspect.signature(function).parameters if name != "self"] + + +@pytest.mark.parametrize(("module", "name", "_used_in"), INTERNAL) +def test_each_internal_name_is_where_pybreeze_imports_it(module, name, _used_in): + assert hasattr(importlib.import_module(module), name), f"{module}.{name} moved" + + +@pytest.mark.parametrize("name", EXPORTED) +def test_each_top_level_name_is_still_exported(name): + import je_editor + + assert name in je_editor.__all__, f"je_editor stopped exporting {name}" + + +class TestTheShapesPyBreezeCalls: + def test_the_main_window_takes_the_arguments_pybreeze_passes(self): + from je_editor import EditorMain + + # PyBreezeMainWindow calls super().__init__(debug_mode, show_system_tray_ray, extend=True). + assert _parameters(EditorMain.__init__)[:3] == ["debug_mode", "show_system_tray_ray", "extend"] + assert callable(EditorMain.clear_code_result) + assert callable(EditorMain.startup_setting) + + def test_an_editor_tab_can_be_saved_the_way_a_run_saves_it(self): + from je_editor import EditorWidget + + # save_current_file_for_run() reads these and calls these. + source = inspect.getsource(EditorWidget) + for attribute in ("current_file", "code_edit", "file_encoding", "line_ending"): + assert f"self.{attribute}" in source, f"EditorWidget has no {attribute}" + assert callable(EditorWidget.mark_ignore_next_file_change) + assert callable(EditorWidget.mark_saved) + + def test_the_save_helpers_take_the_arguments_pybreeze_passes(self): + write = _internal("je_editor.utils.file.save.save_file", "write_file_with_encoding") + save_as = _internal( + "je_editor.pyside_ui.dialog.file_dialog.save_file_dialog", + "choose_file_get_save_file_path") + + assert _parameters(write)[:4] == ["file_path", "content", "encoding", "line_ending"] + assert len(_parameters(save_as)) == 1 + assert _internal("je_editor.utils.encodings.text_codec", "LINE_ENDING_LF") == "\n" + + def test_the_interpreter_search_takes_one_directory(self): + search = _internal("je_editor.utils.venv_check.check_venv", "check_and_choose_venv") + assert len(_parameters(search)) == 1 + + def test_the_output_colours_are_there(self): + colours = _internal( + "je_editor.pyside_ui.main_ui.save_settings.user_color_setting_file", + "actually_color_dict") + assert {"normal_output_color", "error_output_color"} <= set(colours) + + def test_the_widgets_build_without_arguments(self): + from PySide6.QtWidgets import QDockWidget, QWidget + + dock = _internal("je_editor.pyside_ui.main_ui.dock.destroy_dock", "DestroyDock") + browser = _internal( + "je_editor.pyside_ui.main_ui.plugin_browser.plugin_browser_widget", + "PluginBrowserWidget") + assert issubclass(dock, QDockWidget) and not _parameters(dock.__init__) + assert issubclass(browser, QWidget) + assert all( + parameter.default is not inspect.Parameter.empty + for name, parameter in inspect.signature(browser.__init__).parameters.items() + if name != "self") + + def test_auto_save_can_be_stopped_and_started_again_on_another_path(self): + # A rename in the file tree stops a tab's save thread and starts another. + module = "je_editor.pyside_ui.code.auto_save.auto_save_manager" + start = _internal(module, "init_new_auto_save_thread") + assert _parameters(start)[:2] == ["file_path", "widget"] + assert isinstance(_internal(module, "auto_save_manager_dict"), dict) + assert isinstance(_internal(module, "file_is_open_manager_dict"), dict) + thread = _internal("je_editor.pyside_ui.code.auto_save.auto_save_thread", "CodeEditSaveThread") + source = inspect.getsource(thread) + assert "self.still_run" in source and "self.file" in source + + def test_a_rename_can_move_what_an_editor_tab_follows_its_file_with(self): + # The file tree moves the tab's watcher, as open_an_file does, and + # reloads what goes by the path + from je_editor import EditorWidget + + source = inspect.getsource(EditorWidget) + assert "self._file_watcher = QFileSystemWatcher" in source + assert "self._ignore_next_change" in source + # A rename puts the unsaved mark back after rename_self_tab clears it + assert "self._is_modified" in inspect.getsource(EditorWidget.rename_self_tab) + assert "self._is_modified = True" in inspect.getsource(EditorWidget._on_text_changed) + code_editor = _internal("je_editor.pyside_ui.code.plaintext_code_edit.code_edit_plaintext", "CodeEditor") + for method in ("reset_highlighter", "load_git_baseline", "start_language_server"): + assert callable(getattr(code_editor, method, None)), method + + def test_a_tab_closes_through_close_tab(self): + # PyBreeze overrides it to ask a tab's may_close() first + from je_editor import EditorMain + + assert _parameters(EditorMain.close_tab) == ["index"] + assert "widget.close()" in inspect.getsource(EditorMain.close_tab) + + def test_a_docked_editor_writes_to_the_file_it_names_when_it_closes(self): + # A rename re-points current_file; the dock saves nothing else + docked = _internal("je_editor.pyside_ui.main_ui.editor.editor_widget_dock", "FullEditorWidget") + assert _parameters(docked.__init__) == ["current_file", "encoding", "line_ending"] + assert "self.current_file" in inspect.getsource(docked.closeEvent) + + def test_the_language_wrapper_has_what_pybreeze_reads(self): + from je_editor import language_wrapper + + for member in ("language", "language_word_dict", "choose_language_dict"): + assert hasattr(language_wrapper, member), member + assert callable(language_wrapper.reset_language) + assert callable(language_wrapper.available_languages) + # PyBreeze adds its strings to these two dicts in place, so they must be + # the very objects the wrapper serves English and Traditional Chinese from. + from je_editor import english_word_dict, traditional_chinese_word_dict + assert language_wrapper.choose_language_dict["English"] is english_word_dict + assert language_wrapper.choose_language_dict["Traditional_Chinese"] is traditional_chinese_word_dict + + +def test_the_list_matches_the_code(): + """Every internal je_editor import in pybreeze/ is listed above, and nothing else is.""" + import pathlib + import re + + import pybreeze + + # Both "import a, b" and "import (\n a, b,\n)": a parenthesised import that + # the check could not read would slip past it unlisted. + pattern = re.compile( + r"^\s*from (je_editor\.[\w.]+) import (?:\(([^)]*)\)|([\w, ]+)$)", re.MULTILINE) + found = set() + for path in pathlib.Path(pybreeze.__file__).parent.rglob("*.py"): + for module, wrapped, plain in pattern.findall(path.read_text(encoding="utf-8")): + names = (wrapped or plain).replace("\n", " ").split(",") + found.update((module, name.strip()) for name in names if name.strip()) + assert found == {(module, name) for module, name, _ in INTERNAL} diff --git a/test/test_utils/test_json_process.py b/test/test_utils/test_json_process.py index 5bc8031d..2aa5e01f 100644 --- a/test/test_utils/test_json_process.py +++ b/test/test_utils/test_json_process.py @@ -58,3 +58,93 @@ def test_json_with_unicode(self): result = reformat_json('{"key": "中文"}') parsed = json.loads(result) assert parsed["key"] == "中文" + + +class TestInputNestedTooDeep: + """The json module recurses per level; deep nesting is a RecursionError, not a crash.""" + + _DEEP = "[" * 100_000 + "]" * 100_000 + + def test_reformat_reports_it_as_bad_json(self): + from pybreeze.utils.exception.exceptions import ITEJsonException + from pybreeze.utils.json_format.json_process import reformat_json + + with pytest.raises(ITEJsonException): + reformat_json(self._DEEP) + + def test_minify_reports_it_as_bad_json(self): + from pybreeze.utils.exception.exceptions import ITEJsonException + from pybreeze.utils.json_format.json_process import minify_json + + with pytest.raises(ITEJsonException): + minify_json(self._DEEP) + + +def test_an_integer_past_the_conversion_limit_comes_back_as_written(): + # Converted to int, it raised ValueError past Python's 4300-digit limit; + # it is valid JSON, and the number is now kept as its own text + from pybreeze.utils.json_format.json_process import minify_json + + digits = "9" * 5000 + assert minify_json('{"n": ' + digits + "}") == '{"n":' + digits + "}" + assert digits in reformat_json('{"n": ' + digits + "}") + + +class TestTheDataIsNotChanged: + """Format and Minify re-lay the JSON out; what it says stays the same.""" + + @pytest.mark.parametrize("number", [ + "1e400", "12345678901234567890123.0", "0.10000000000000000555", "1.50", "2E3", "-0.0", "1e-400"]) + def test_a_number_is_written_as_it_was(self, number): + from pybreeze.utils.json_format.json_process import minify_json + + # float() made 1e400 Infinity, which is not JSON, and rounded the rest + assert minify_json(f"[{number}]") == f"[{number}]" + assert f" {number}\n" in reformat_json(f"[{number}]") + + def test_characters_are_written_as_they_were(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json('{"n": "中文"}') == '{"n":"中文"}' + assert '"中文"' in reformat_json('{"n": "中文"}') + + def test_a_string_that_looks_like_a_placeholder_stays_a_string(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json('["\\u0000abc:0\\u0000", 5]') == '["\\u0000abc:0\\u0000",5]' + + def test_a_key_given_twice_is_reported_not_dropped(self): + from pybreeze.utils.json_format.json_process import minify_json + + with pytest.raises(ITEJsonException) as raised: + minify_json('{"a": 1, "a": 2}') + assert "'a'" in str(raised.value) + with pytest.raises(ITEJsonException): + reformat_json('{"a": 1, "a": 2}') + + def test_the_same_key_in_two_objects_is_fine(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json('[{"a": 1}, {"a": 2}]') == '[{"a":1},{"a":2}]' + + @pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) + def test_nan_and_infinity_are_not_json(self, constant): + from pybreeze.utils.json_format.json_process import minify_json + + with pytest.raises(ITEJsonException): + minify_json(f"[{constant}]") + + +class TestLoneSurrogates: + """Written out raw, the text view dropped them: an escaped lone surrogate came out as "".""" + + def test_reformat_keeps_the_escape(self): + result = reformat_json(r'{"a": "\ud83d"}') + + assert r'"\ud83d"' in result + assert json.loads(result) == {"a": "\ud83d"} + + def test_minify_keeps_the_escape_and_a_pair_stays_one_character(self): + from pybreeze.utils.json_format.json_process import minify_json + + assert minify_json(r'{"a": "\ud83d", "b": "\ud83d\ude00"}') == '{"a":"\\ud83d","b":"\U0001f600"}' diff --git a/test/test_utils/test_jupyter_lifecycle.py b/test/test_utils/test_jupyter_lifecycle.py new file mode 100644 index 00000000..36c3687f --- /dev/null +++ b/test/test_utils/test_jupyter_lifecycle.py @@ -0,0 +1,236 @@ +"""The embedded JupyterLab: how its server is started, and that it goes when the tab does. + +The server runs with no token, so the loopback bind and the same-origin rule are +what keep other software away from it; and it outlives its launcher thread, so the +tab has to stop it by name. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread, jupyter_lab_widget +from test_utils.started_window import run_started_window + +# The readiness wait as the launcher has it, before a test replaces it +_REAL_WAIT_UNTIL_READY = jupyter_lab_thread.JupyterLauncherThread._wait_until_ready + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeServer: + """Stands in for the JupyterLab process.""" + + def __init__(self, argv, **options) -> None: + self.argv = argv + self.options = options + self.terminated = False + self.returncode = None + + def terminate(self) -> None: + self.terminated = True + + def poll(self): + return self.returncode + + +@pytest.fixture() +def launched(monkeypatch) -> list: + """Start the launcher's run() against a fake server and collect what it started.""" + started: list = [] + + def popen(argv, **options): + server = FakeServer(argv, **options) + started.append(server) + return server + + monkeypatch.setattr(jupyter_lab_thread, "get_venv_python", lambda: "python") + monkeypatch.setattr(jupyter_lab_thread, "is_jupyter_installed", lambda _python: True) + monkeypatch.setattr(jupyter_lab_thread.subprocess, "Popen", popen) + monkeypatch.setattr( + jupyter_lab_thread.JupyterLauncherThread, "_wait_until_ready", lambda self, port: None) + return started + + +class TestHowTheServerIsStarted: + def test_it_is_bound_to_this_machine_and_takes_no_other_origin(self, app, launched): + thread = jupyter_lab_thread.JupyterLauncherThread() + + thread.run() + + argv = launched[0].argv + assert "--ServerApp.ip=localhost" in argv + # With no token, a wildcard origin would let any page the user visits + # drive this server's API and kernel sockets. + assert not [arg for arg in argv if arg.startswith("--ServerApp.allow_origin")] + thread.stop() + + def test_its_output_goes_to_a_file_not_a_pipe_nobody_reads(self, app, launched): + import subprocess + + thread = jupyter_lab_thread.JupyterLauncherThread() + + thread.run() + + options = launched[0].options + assert options["stdout"] is not subprocess.PIPE + assert options["stderr"] is subprocess.STDOUT + thread.stop() + + def test_an_early_exit_reports_what_the_server_said(self, app, launched, monkeypatch): + thread = jupyter_lab_thread.JupyterLauncherThread(startup_timeout=5) + thread.run() + thread._output.write("ImportError: jupyterlab is broken\n") + thread._output.flush() + launched[0].returncode = 1 + monkeypatch.setattr( + jupyter_lab_thread.JupyterLauncherThread, "_port_open", staticmethod(lambda port: False)) + + with pytest.raises(RuntimeError, match="jupyterlab is broken"): + _REAL_WAIT_UNTIL_READY(thread, 1) + thread.stop() + + def test_stopping_twice_is_harmless(self, app, launched): + thread = jupyter_lab_thread.JupyterLauncherThread() + thread.run() + + thread.stop() + thread.stop() + + assert launched[0].terminated + + +class FinishedLauncher: + """A launcher whose thread has finished: the server it started is still up.""" + + def __init__(self, **_options) -> None: + self.stopped = False + from PySide6.QtCore import QObject, Signal + + class Signals(QObject): + status_update = Signal(str) + server_ready = Signal(str) + error_occurred = Signal(str) + + self._signals = Signals() + self.status_update = self._signals.status_update + self.server_ready = self._signals.server_ready + self.error_occurred = self._signals.error_occurred + + def start(self) -> None: + """Nothing to start in a stand-in.""" + + def isRunning(self) -> bool: + return False + + def stop(self) -> None: + self.stopped = True + + +class TestClosingTheTab: + def test_the_server_is_stopped_after_the_lab_has_loaded(self, app, monkeypatch): + monkeypatch.setattr(jupyter_lab_widget, "JupyterLauncherThread", FinishedLauncher) + tab = jupyter_lab_widget.JupyterLabWidget() + + tab.close() + + assert tab.thread.stopped + + +_CLOSE_WITH_A_TOOL_TAB_AND_DOCK = """ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget +from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock +closed = [] +class Tool(QWidget): + def __init__(self, name): + super().__init__() + self.name = name + def closeEvent(self, event): + closed.append(self.name) + super().closeEvent(event) +window.tab_widget.addTab(Tool("tab"), "Tool") +dock = DestroyDock() +dock.setWidget(Tool("dock")) +window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) +window.close() +result = {"closed": sorted(closed)} +""" + + +def test_closing_the_ide_closes_its_own_tabs_and_docks(tmp_path): + # JEditor closes editor tabs only; a JupyterLab server, an SSH session or a + # diagram's downloads live in PyBreeze's own tabs and docks. + seen = run_started_window(tmp_path, _CLOSE_WITH_A_TOOL_TAB_AND_DOCK) + + assert "tab" in seen["closed"] + assert "dock" in seen["closed"] + + +class TestStoppingBeforeTheServerStarts: + """A tab closed while the launcher checks or installs gets no server afterwards.""" + + def test_a_stopped_launcher_starts_nothing(self, app, launched, monkeypatch): + thread = jupyter_lab_thread.JupyterLauncherThread() + + def installed_while_the_tab_closes(_python: str) -> bool: + thread.stop() # the tab closes during the check (or the install) + return True + + monkeypatch.setattr(jupyter_lab_thread, "is_jupyter_installed", installed_while_the_tab_closes) + + thread.run() + + assert launched == [] + + def test_closing_the_tab_does_not_wait_for_the_launcher(self, app, monkeypatch): + import threading + import time + + from pybreeze.pybreeze_ui.thread_keeper import is_kept + + installing = threading.Event() + monkeypatch.setattr( + jupyter_lab_thread.JupyterLauncherThread, "run", lambda self: installing.wait(5)) + tab = jupyter_lab_widget.JupyterLabWidget() + launcher = tab.thread + + tab.close() # returns with the install still going + + assert is_kept(launcher) + assert launcher._stopped.is_set() + installing.set() + deadline = time.monotonic() + 5 + while is_kept(launcher): + assert time.monotonic() < deadline, "the launcher was never let go" + app.processEvents() + time.sleep(0.01) + + +def test_a_closed_tab_is_deleted_with_its_web_view(app, monkeypatch): + # close_tab removes the tab but keeps the widget: every closed tab kept a + # web view and its renderer until the IDE exited + from PySide6.QtCore import QCoreApplication, QEvent, Qt + + monkeypatch.setattr(jupyter_lab_widget, "JupyterLauncherThread", FinishedLauncher) + tab = jupyter_lab_widget.JupyterLabWidget() + gone: list = [] + tab.browser.destroyed.connect(lambda *_: gone.append("browser")) + + # Read before close(), which clears it as it schedules the delete + assert tab.testAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + tab.close() + # Only this tab's: every deferred delete reached objects earlier tests had left + QCoreApplication.sendPostedEvents(tab, QEvent.Type.DeferredDelete) + + assert gone == ["browser"] diff --git a/test/test_utils/test_jupyter_ready.py b/test/test_utils/test_jupyter_ready.py index a4f4ba0e..e1dc663f 100644 --- a/test/test_utils/test_jupyter_ready.py +++ b/test/test_utils/test_jupyter_ready.py @@ -16,6 +16,8 @@ def qt_app(): app = QApplication([]) except Exception as exc: # pragma: no cover - no usable Qt platform pytest.skip(f"Cannot start QApplication: {exc}") + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + update_language_dict() return app @@ -51,12 +53,18 @@ def _thread(qt_app): class TestWaitUntilReady: def test_early_exit_fails_fast(self, qt_app): + import tempfile + thread = _thread(qt_app) thread.process = _DeadProcess() + # What the server wrote goes to a file the launcher keeps, not a pipe. + thread._output = tempfile.TemporaryFile(mode="w+", encoding="utf-8") + thread._output.write("ImportError: jupyterlab not installed\n") with pytest.raises(RuntimeError) as exc: thread._wait_until_ready(59999) assert "exited early" in str(exc.value) assert "jupyterlab not installed" in str(exc.value) + thread._output.close() def test_returns_when_port_open(self, qt_app, monkeypatch): thread = _thread(qt_app) @@ -66,6 +74,42 @@ def test_returns_when_port_open(self, qt_app, monkeypatch): assert thread._wait_until_ready(59999) is None +class TestTheReasonIsInTheIdeLanguage: + """A start that timed out or a server that exited read in English whatever the IDE spoke.""" + + @pytest.fixture() + def chinese(self, monkeypatch): + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as word, + ) + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + monkeypatch.setattr(mod.language_wrapper, "language_word_dict", word) + + def test_a_timeout(self, qt_app, chinese, monkeypatch): + thread = _thread(qt_app) + thread.startup_timeout = 0 + thread.process = _AliveProcess() + monkeypatch.setattr(thread, "_port_open", lambda port: False) + + with pytest.raises(TimeoutError, match=r"^JupyterLab 啟動超時 \(0s\)$"): + thread._wait_until_ready(59999) + + def test_an_early_exit(self, qt_app, chinese): + import tempfile + + thread = _thread(qt_app) + thread.process = _DeadProcess() + with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as output: + thread._output = output + output.write("port in use {0}\n") + + with pytest.raises(RuntimeError) as exc: + thread._wait_until_ready(59999) + + assert str(exc.value).startswith("JupyterLab 提早結束(結束代碼 1):") + assert "port in use {0}" in str(exc.value) + + class TestRunCleansUpOnFailure: def test_startup_failure_terminates_orphan_process(self, qt_app, monkeypatch): import pybreeze.pybreeze_ui.jupyter_lab_gui.jupyter_lab_thread as mod @@ -96,3 +140,120 @@ def _boom(_port): # The half-started server must be terminated, not left holding the port. assert proc.terminated is True assert errors + + +class TestWhatAFailureShows: + def _run(self, monkeypatch, fail, stop_first=False): + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + monkeypatch.setattr(mod, "choose_python", lambda _chosen: fail()) + thread = mod.JupyterLauncherThread() + if stop_first: + thread.stop() + errors: list = [] + logged: list = [] + thread.error_occurred.connect(errors.append) + monkeypatch.setattr(mod.pybreeze_logger, "error", lambda *args: logged.append(args)) + thread.run() + return errors, logged + + def test_the_reason_reaches_the_tab_without_the_traceback(self, qt_app, monkeypatch): + def no_venv(): + raise RuntimeError("Cannot find venv python executable") + + errors, logged = self._run(monkeypatch, no_venv) + + assert errors == ["Cannot find venv python executable"] + assert logged # the traceback still goes to the log + + def test_a_tab_closed_during_startup_is_not_a_failure(self, qt_app, monkeypatch): + def exited(): + raise RuntimeError("JupyterLab exited early (code 1): ") + + # stop() ends the server; the wait then sees it gone. That used to be + # logged as "JupyterLab launch failed". + errors, logged = self._run(monkeypatch, exited, stop_first=True) + + assert errors == [] + assert logged == [] + + def test_the_tab_says_why_as_plain_text(self, qt_app, monkeypatch): + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_widget + from PySide6.QtCore import Qt + + update_language_dict() + monkeypatch.setattr(jupyter_lab_widget.JupyterLauncherThread, "start", lambda self: None) + tab = jupyter_lab_widget.JupyterLabWidget() + + tab.show_error("ERROR: No matching distribution for jupyterlab") + + # It used to say only "init failed". + assert "No matching distribution" in tab.status_label.text() + assert tab.status_label.textFormat() == Qt.TextFormat.PlainText + tab.close() + tab.deleteLater() + + +class TestWhichInterpreterRunsTheLab: + def test_the_one_chosen_in_the_ide_comes_first(self, monkeypatch): + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + monkeypatch.setattr(mod, "get_venv_python", lambda: "venv-python") + + assert mod.choose_python("C:/envs/project/python.exe") == "C:/envs/project/python.exe" + assert mod.choose_python(None) == "venv-python" + + def test_without_a_venv_the_ides_own_is_used(self, monkeypatch): + # It raised "Cannot find venv python executable" and the lab never started + import sys + + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + def no_venv(): + raise RuntimeError("Cannot find venv python executable") + + monkeypatch.setattr(mod, "get_venv_python", no_venv) + + assert mod.choose_python(None) == sys.executable + + def test_installed_is_asked_of_the_interpreter_not_of_pip(self, tmp_path, monkeypatch): + # A venv made without pip failed "pip show" with jupyterlab installed + import os + import subprocess + import sys + + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + asked: list = [] + real_run = subprocess.run + + def run(argv, **kwargs): + asked.append(argv) + return real_run(argv, **kwargs) + + monkeypatch.setattr(mod.subprocess, "run", run) + (tmp_path / "jupyterlab").mkdir() + (tmp_path / "jupyterlab" / "__init__.py").write_text("", encoding="utf-8") + + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + found = mod.is_jupyter_installed(sys.executable) + monkeypatch.setenv("PYTHONPATH", os.devnull) + monkeypatch.setattr(mod, "_HAS_JUPYTERLAB", mod._HAS_JUPYTERLAB.replace("jupyterlab", "no_such_lab")) + missing = mod.is_jupyter_installed(sys.executable) + + assert (found, missing) == (True, False) + assert all("pip" not in argv for argv in asked) + + +def test_the_server_is_told_not_to_move_to_another_port(monkeypatch): + # With port_retries left at 50 it moved to the next free port, and the tab + # waited on the one it was given + from pybreeze.pybreeze_ui.jupyter_lab_gui import jupyter_lab_thread as mod + + started: list = [] + monkeypatch.setattr(mod.subprocess, "Popen", lambda argv, **_kwargs: started.append(argv)) + mod.JupyterLauncherThread()._start_server("python", 8888) + + assert "--ServerApp.port_retries=0" in started[0] + assert "--ServerApp.port=8888" in started[0] diff --git a/test/test_utils/test_jwt_decoder.py b/test/test_utils/test_jwt_decoder.py index 80930199..abab5859 100644 --- a/test/test_utils/test_jwt_decoder.py +++ b/test/test_utils/test_jwt_decoder.py @@ -91,6 +91,13 @@ def test_none_returns_none(self): def test_out_of_range_returns_none(self): assert format_timestamp_claim(10 ** 30) is None + def test_a_claim_before_1970_is_shown(self): + # It was silently left out on Windows. + assert format_timestamp_claim(-86400) == "1969-12-31T00:00:00+00:00" + + def test_not_a_number_returns_none(self): + assert format_timestamp_claim(float("nan")) is None + class TestHumanizedTimestampClaims: def test_extracts_known_claims(self): @@ -107,3 +114,106 @@ def test_empty_payload(self): def test_nbf_and_auth_time(self): payload = {"nbf": 1609459200, "auth_time": 1609459200} assert set(humanized_timestamp_claims(payload)) == {"nbf", "auth_time"} + + +def test_a_payload_nested_past_the_recursion_limit_is_a_decode_error(): + def segment(obj_text: str) -> str: + return base64.urlsafe_b64encode(obj_text.encode("utf-8")).decode("ascii").rstrip("=") + + token = ".".join([segment('{"alg": "none"}'), segment("[" * 100000 + "]" * 100000), ""]) + with pytest.raises(JwtDecodeException): + decode_jwt(token) + + +class TestATokenPastedWithSomethingAroundIt: + """Bearer prefixes, quotes and line breaks come along when a token is copied.""" + + @pytest.mark.parametrize("header", [{"alg": "none"}, {"alg": "ES256", "kid": "a"}, {"alg": "HS256"}]) + @pytest.mark.parametrize("wrap", ["Bearer {}", '"{}"', "Authorization: Bearer {}\n", "{}"]) + def test_the_token_inside_is_decoded(self, header, wrap): + # "Bearer eyJ..." always failed; a quoted token failed or kept the quote in + # its signature depending on the header's length + token = _make_jwt(header, {"sub": "1"}) + + decoded = decode_jwt(wrap.format(token)) + + assert decoded.header == header + assert decoded.signature == "sig" + + def test_a_token_wrapped_over_lines_is_joined(self): + token = _make_jwt({"alg": "HS256"}, {"sub": "1", "name": "a long enough name"}) + + decoded = decode_jwt(token[:20] + "\n" + token[20:40] + "\r\n " + token[40:]) + + assert decoded.payload == {"sub": "1", "name": "a long enough name"} + + def test_a_segment_with_characters_outside_base64url_is_refused(self): + # Non-strict decoding dropped them and decoded what was left + token = _make_jwt({"alg": "HS256"}, {"sub": "1"}) + header, payload, signature = token.split(".") + + with pytest.raises(JwtDecodeException): + decode_jwt(f"{header[:4]}!{header[4:]}.{payload}.{signature}") + + +class TestTheSegmentsAsShown: + """The decoder keeps each segment's JSON text, and shows it as written.""" + + @staticmethod + def _raw_jwt(payload_text: str) -> str: + encoded = base64.urlsafe_b64encode(payload_text.encode("utf-8")).decode("ascii").rstrip("=") + return f"{_segment({'alg': 'none'})}.{encoded}.sig" + + def test_numbers_keep_their_text(self): + from pybreeze.utils.jwt_tools.jwt_decoder import shown_json + + decoded = decode_jwt(self._raw_jwt('{"n": 1e400, "big": 12345678901234567890123.5}')) + shown = shown_json(decoded.payload_json, decoded.payload) + + # json.dumps wrote Infinity, which is not JSON, and rounded the long number + assert '"n": 1e400' in shown + assert '"big": 12345678901234567890123.5' in shown + + def test_a_repeated_claim_falls_back_to_the_decoded_value(self): + from pybreeze.utils.jwt_tools.jwt_decoder import shown_json + + decoded = decode_jwt(self._raw_jwt('{"a": 1, "a": 2}')) + + assert json.loads(shown_json(decoded.payload_json, decoded.payload)) == {"a": 2} + + def test_the_keys_are_sorted_unless_asked_not_to(self): + from pybreeze.utils.jwt_tools.jwt_decoder import shown_json + + decoded = decode_jwt(self._raw_jwt('{"z": 1, "a": 2}')) + + assert shown_json(decoded.payload_json, decoded.payload).index('"a"') < \ + shown_json(decoded.payload_json, decoded.payload).index('"z"') + unsorted = shown_json(decoded.payload_json, decoded.payload, sort_keys=False) + assert unsorted.index('"z"') < unsorted.index('"a"') + + +class TestWhereTheTokenEnds: + TOKEN = _make_jwt({"alg": "none"}, {"a": 1}) + + def test_the_line_after_it_is_not_part_of_the_signature(self): + # All whitespace went: the signature read "signextline" + assert decode_jwt(self.TOKEN + "\nnext line").signature == "sig" + + def test_a_token_wrapped_across_lines_is_joined(self): + wrapped = f'"{self.TOKEN[:20]}\n {self.TOKEN[20:]}",' + assert decode_jwt(wrapped).payload == {"a": 1} + + def test_a_fourth_segment_is_refused_not_dropped(self): + with pytest.raises(JwtDecodeException, match="three"): + decode_jwt(self.TOKEN + ".extra") + + def test_a_closing_full_stop_is_not_a_segment(self): + assert decode_jwt(f"The token is {self.TOKEN}.").signature == "sig" + + +def test_a_header_whose_encoding_is_not_eyj_is_found_after_bearer(): + # '{ "alg"' encodes to "eyAi", '{\n' to "ewo": only "eyJ" was looked for + header = base64.urlsafe_b64encode(b'{ "alg": "none"}').decode("ascii").rstrip("=") + token = f"{header}.{_segment({'a': 1})}.s" + + assert decode_jwt(f"Bearer {token}").header == {"alg": "none"} diff --git a/test/test_utils/test_language_parity.py b/test/test_utils/test_language_parity.py index 0cbaf134..43191802 100644 --- a/test/test_utils/test_language_parity.py +++ b/test/test_utils/test_language_parity.py @@ -11,7 +11,12 @@ pybreeze_traditional_chinese_word_dict as ZH, ) -_GET_KEY_RE = re.compile(r'language_word_dict\.get\(\s*["\']([A-Za-z0-9_]+)["\']') +# language_word_dict.get("key"), the same through a local alias +# (word = language_wrapper.language_word_dict; word.get("key")), and the +# diagram editor's _lang("key", fallback), whose English fallback hid a key +# missing from both dicts +_GET_KEY_RE = re.compile( + r'(?:\b(?:language_word_dict|word_dict|word)\.get|\b_lang)\(\s*["\']([A-Za-z0-9_]+)["\']') def _placeholders(text: str) -> set[str]: @@ -19,7 +24,7 @@ def _placeholders(text: str) -> set[str]: def _code_used_keys() -> dict[str, str]: - """Map every literal ``language_word_dict.get("key")`` key to a source file.""" + """Map every literal key the code looks up (see ``_GET_KEY_RE``) to a source file.""" root = pathlib.Path(pybreeze.__file__).parent used: dict[str, str] = {} for path in root.rglob("*.py"): @@ -53,6 +58,16 @@ def test_placeholders_match_across_languages(self): } assert not mismatched, f"Placeholder mismatches between languages: {mismatched}" + def test_no_word_is_written_twice(self): + # Four Chinese menu entries read "運行 Multi WebRunner 腳本 腳本並寄信" + doubled = { + key: value + for words in (EN, ZH) + for key, value in words.items() + if re.search(r"(? 10 + missing = sorted(f"header_finding_{code}" for code in codes if f"header_finding_{code}" not in EN) + missing += sorted(f"header_analyzer_level_{level}" for level in levels + if f"header_analyzer_level_{level}" not in EN) + assert not missing, f"Header analyzer keys missing from the dict: {missing}" + + +class TestEveryLanguageServesPyBreezeStrings: + def test_each_registered_language_resolves_every_key(self): + # JEditor serves each language but English from a merged copy of its dict + # and English's. Once PyBreeze's strings are in, every language, even one + # PyBreeze does not translate, must resolve every key the code asks for. + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + update_language_dict() + used = _code_used_keys() + original = language_wrapper.language + missing = {} + try: + for language in language_wrapper.available_languages(): + language_wrapper.reset_language(language) + blank = sorted( + key for key in used if not language_wrapper.language_word_dict.get(key)) + if blank: + missing[language] = blank + finally: + language_wrapper.reset_language(original) + assert not missing, f"Keys a language cannot resolve: {missing}" + + def test_the_window_is_called_pybreeze_in_every_language(self): + from je_editor import language_wrapper + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + update_language_dict() + original = language_wrapper.language + names = {} + try: + for language in language_wrapper.available_languages(): + language_wrapper.reset_language(language) + names[language] = language_wrapper.language_word_dict["application_name"] + finally: + language_wrapper.reset_language(original) + assert set(names.values()) == {"PyBreeze"}, names diff --git a/test/test_utils/test_logger.py b/test/test_utils/test_logger.py index 78801343..31e0b9ec 100644 --- a/test/test_utils/test_logger.py +++ b/test/test_utils/test_logger.py @@ -57,3 +57,93 @@ def test_handler_formatter(self): handler = PyBreezeLogger(filename=log_file) assert handler.formatter is not None handler.close() + + +class TestWhereTheLogGoes: + """Importing writes nothing; the file is under the user's home, in UTF-8, appended.""" + + def test_the_default_is_under_the_data_directory(self, monkeypatch): + from pybreeze.utils.logging import logger as logger_module + + monkeypatch.delenv(logger_module.LOG_FILE_ENV, raising=False) + + path = logger_module.default_log_file() + + assert path.parts[-3:] == (".pybreeze", "logs", "PyBreeze.log") + + def test_the_environment_can_name_another_file(self, monkeypatch, tmp_path): + from pybreeze.utils.logging import logger as logger_module + + monkeypatch.setenv(logger_module.LOG_FILE_ENV, str(tmp_path / "elsewhere.log")) + + assert logger_module.default_log_file() == tmp_path / "elsewhere.log" + + def test_a_data_folder_the_log_makes_is_its_owners_only(self, monkeypatch, tmp_path): + # The log is often written first: it made ~/.pybreeze with the default + # mode, and pybreeze_data_dir then left that folder as it was + from pathlib import Path + + from pybreeze.utils.logging import logger as logger_module + + data = tmp_path / ".pybreeze" + monkeypatch.setattr(logger_module, "pybreeze_data_path", lambda: data) + modes: dict = {} + made = Path.mkdir + + def record(path, mode=0o777, parents=False, exist_ok=False): + modes.setdefault(Path(path), mode) + made(path, mode=mode, parents=parents, exist_ok=exist_ok) + + monkeypatch.setattr(Path, "mkdir", record) + handler = PyBreezeLogger(filename=str(data / "logs" / "PyBreeze.log")) + handler.close() + + assert modes[data] == 0o700 + assert (data / "logs").is_dir() + + def test_the_package_handler_opens_nothing_at_import(self): + from pybreeze.utils.logging.logger import file_handler + + # delay=True: no stream until the first record. + assert file_handler.delay is True + + def test_text_outside_the_locale_code_page_is_kept(self, tmp_path): + log_file = tmp_path / "unicode.log" + handler = PyBreezeLogger(filename=str(log_file)) + test_logger = logging.getLogger("test_pybreeze_unicode") + test_logger.addHandler(handler) + test_logger.setLevel(logging.DEBUG) + + test_logger.info("規則檢索 ÜÄÖ ключ") + handler.close() + test_logger.removeHandler(handler) + + assert "規則檢索 ÜÄÖ ключ" in log_file.read_text(encoding="utf-8") + + def test_a_second_process_appends_instead_of_wiping(self, tmp_path): + log_file = tmp_path / "shared.log" + for message in ("first run", "second run"): + handler = PyBreezeLogger(filename=str(log_file)) + test_logger = logging.getLogger("test_pybreeze_append") + test_logger.addHandler(handler) + test_logger.setLevel(logging.DEBUG) + test_logger.info(message) + handler.close() + test_logger.removeHandler(handler) + + text = log_file.read_text(encoding="utf-8") + assert "first run" in text and "second run" in text + + def test_a_file_that_cannot_be_opened_turns_logging_off_not_the_caller(self, tmp_path): + import pytest + + blocked = tmp_path / "a_file_not_a_folder" + blocked.write_text("", encoding="utf-8") + handler = PyBreezeLogger(filename=str(blocked / "PyBreeze.log"), delay=True) + test_logger = logging.getLogger("test_pybreeze_blocked") + test_logger.addHandler(handler) + + with pytest.warns(RuntimeWarning): + test_logger.error("goes nowhere") + handler.close() + test_logger.removeHandler(handler) diff --git a/test/test_utils/test_long_scripts.py b/test/test_utils/test_long_scripts.py new file mode 100644 index 00000000..91373cc1 --- /dev/null +++ b/test/test_utils/test_long_scripts.py @@ -0,0 +1,85 @@ +"""A script too long for a Windows command line still runs. + +Running the tab in front passes its script as ``--execute_str``; past Windows' +limit of 32,767 characters the child did not start at all ("could not start"). +""" +from __future__ import annotations + +import json +import os +import sys +import time + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest # noqa: E402 + +_REPORTER = """ +import json, sys +flag, value = sys.argv[1], sys.argv[2] +if flag == "--execute_file": + with open(value, encoding="ascii") as handle: + value = handle.read() +actions = json.loads(value) +if isinstance(actions, str): + actions = json.loads(actions) # on Windows --execute_str carries the script JSON-encoded once more +print(json.dumps({"flag": flag, "count": len(actions), "last": actions[-1]})) +""" + + +@pytest.fixture(scope="module") +def app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture() +def reporter(tmp_path, monkeypatch): + """A package that says which flag it was given and what it read, in ASCII.""" + (tmp_path / "report_args.py").write_text(_REPORTER, encoding="utf-8") + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + return "report_args" + + +def _run(app, package: str, script: str): + from pybreeze.extend.process_executor.python_task_process_manager import TaskProcessManager + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + class Window: + python_compiler = sys.executable + + window = CodeWindow() + window.main_window = Window() + manager = TaskProcessManager(window) + manager.renew_path = lambda: True + manager.compiler_path = sys.executable + manager.start_test_process(package, script) + written = manager._script_file + deadline = time.monotonic() + 30 + while manager.still_run_program and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.02) + output = window.code_result.toPlainText() + report = json.loads(next(line for line in output.splitlines() if line.startswith("{"))) + return report, written, manager + + +@pytest.mark.skipif(sys.platform != "win32", reason="the command-line limit is Windows'") +def test_a_long_script_goes_as_a_file_and_the_file_goes_after(app, reporter): + actions = [["AC_type_keyboard", {"text": "中文 " + "x" * 50}] for _ in range(600)] + script = json.dumps(actions, ensure_ascii=False) + assert len(script) > 32_767 + + report, written, manager = _run(app, reporter, script) + + assert report == {"flag": "--execute_file", "count": 600, "last": actions[-1]} + assert written is not None and not written.exists() + assert manager._script_file is None + + +def test_a_short_script_still_goes_on_the_command_line(app, reporter): + report, written, _manager = _run(app, reporter, '[["AC_type_keyboard", {"text": "hi"}]]') + + assert report == {"flag": "--execute_str", "count": 1, "last": ["AC_type_keyboard", {"text": "hi"}]} + assert written is None diff --git a/test/test_utils/test_mail_report.py b/test/test_utils/test_mail_report.py new file mode 100644 index 00000000..2cbc4e32 --- /dev/null +++ b/test/test_utils/test_mail_report.py @@ -0,0 +1,280 @@ +"""Mailing a run's report: off the UI thread, and every failure logged rather than raised.""" +from __future__ import annotations + +import os +import sys +import threading +import time +import types +from unittest.mock import MagicMock + +import pytest + +from pybreeze.extend.mail_thunder_extend import mail_thunder_setting as mail + + +class FakeSmtp: + """SMTPWrapper's surface, recording what happens to it.""" + + instances: list[FakeSmtp] = [] + connect_error: Exception | None = None + logs_in = True + send_error: Exception | None = None + + def __init__(self) -> None: + if FakeSmtp.connect_error is not None: + raise FakeSmtp.connect_error + self.login_state = False + self.sent: list = [] + self.quit_called = False + FakeSmtp.instances.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + self.quit_called = True + + def later_init(self) -> None: + self.login_state = FakeSmtp.logs_in + + def create_message_with_attach(self, html, settings, path, use_html) -> dict: + return {"html": html, **settings, "path": path, "use_html": use_html} + + def send_message(self, message) -> None: + if FakeSmtp.send_error is not None: + raise FakeSmtp.send_error + self.sent.append(message) + + +class MailThunderException(Exception): + pass + + +@pytest.fixture() +def mail_thunder(monkeypatch): + """A stand-in je_mail_thunder with a user in its content file.""" + FakeSmtp.instances = [] + FakeSmtp.connect_error = None + FakeSmtp.logs_in = True + FakeSmtp.send_error = None + package = types.ModuleType("je_mail_thunder") + package.SMTPWrapper = FakeSmtp + package.read_output_content = lambda: {"user": "tester@example.com"} + package.get_mail_thunder_os_environ = lambda: {} + exceptions = types.ModuleType("je_mail_thunder.utils.exception.exceptions") + exceptions.MailThunderException = MailThunderException + monkeypatch.setitem(sys.modules, "je_mail_thunder", package) + monkeypatch.setitem(sys.modules, "je_mail_thunder.utils.exception.exceptions", exceptions) + return package + + +@pytest.fixture() +def logger(monkeypatch) -> MagicMock: + fake = MagicMock() + monkeypatch.setattr(mail, "pybreeze_logger", fake) + return fake + + +@pytest.fixture() +def report(tmp_path) -> str: + path = tmp_path / "report.html" + path.write_text("ok", encoding="utf-8") + return str(path) + + +def _logged(logger: MagicMock) -> str: + return " ".join(str(arg) for call in logger.error.call_args_list for arg in call.args) + + +class TestSendAfterTest: + def test_it_returns_before_the_mail_is_sent(self, monkeypatch): + sending = threading.Event() + release = threading.Event() + + def slow_send(_path, *, not_before=None) -> None: + sending.set() + release.wait(5) + + monkeypatch.setattr(mail, "send_report", slow_send) + + mail.send_after_test("report.html") # the mail server has not answered + + assert sending.wait(5) + release.set() + + +class TestSendReport: + def test_the_report_goes_to_the_user(self, mail_thunder, logger, report): + mail.send_report(report) + + (smtp,) = FakeSmtp.instances + assert smtp.sent == [{ + "html": "ok", "Subject": "Test Report", + "To": "tester@example.com", "From": "tester@example.com", + "path": report, "use_html": True, + }] + assert smtp.quit_called + logger.error.assert_not_called() + + def test_the_user_can_come_from_the_environment(self, mail_thunder, logger, report): + mail_thunder.read_output_content = lambda: None + mail_thunder.get_mail_thunder_os_environ = lambda: {"mail_thunder_user": "env@example.com"} + + mail.send_report(report) + + assert FakeSmtp.instances[0].sent[0]["To"] == "env@example.com" + + def test_no_user_means_no_connection(self, mail_thunder, logger, report): + mail_thunder.read_output_content = lambda: {} + + mail.send_report(report) + + assert FakeSmtp.instances == [] + assert "mail user" in _logged(logger) + + def test_a_missing_report_means_no_connection(self, mail_thunder, logger, tmp_path): + reason = mail.send_report(str(tmp_path / "absent.html")) + + assert FakeSmtp.instances == [] + assert reason == "the run wrote no absent.html" + assert reason in _logged(logger) + + def test_a_failed_login_is_logged_and_the_connection_closed(self, mail_thunder, logger, report): + FakeSmtp.logs_in = False + + mail.send_report(report) + + (smtp,) = FakeSmtp.instances + assert smtp.sent == [] and smtp.quit_called + assert mail.send_html_exception_tag in _logged(logger) + + @pytest.mark.parametrize("error", [ConnectionRefusedError("refused"), MailThunderException("bad")]) + def test_a_failed_send_is_logged_and_the_connection_closed(self, mail_thunder, logger, report, error): + FakeSmtp.send_error = error + + mail.send_report(report) + + assert FakeSmtp.instances[0].quit_called + assert "Failed to send report" in _logged(logger) + + def test_an_unreachable_server_is_logged(self, mail_thunder, logger, report): + FakeSmtp.connect_error = TimeoutError("timed out") + + mail.send_report(report) + + assert "timed out" in _logged(logger) + + def test_a_report_that_is_not_utf8_is_logged(self, mail_thunder, logger, tmp_path): + path = tmp_path / "report.html" + path.write_bytes(b"\xff\xfe\x00bad") + + mail.send_report(str(path)) + + assert FakeSmtp.instances == [] + assert "Failed to send report" in _logged(logger) + + def test_without_je_mail_thunder_it_is_logged(self, monkeypatch, logger, report): + monkeypatch.setitem(sys.modules, "je_mail_thunder", None) # makes the import fail + + mail.send_report(report) + + assert "je_mail_thunder" in _logged(logger) + + +class TestAReportFromAnEarlierRun: + """A run that wrote no report used to mail the one an earlier run left.""" + + def test_a_report_older_than_the_run_is_not_sent(self, mail_thunder, logger, report): + an_hour_ago = time.time() - 3600 + os.utime(report, (an_hour_ago, an_hour_ago)) + + reason = mail.send_report(report, not_before=time.time()) + + assert FakeSmtp.instances == [] + assert "earlier run" in reason + + def test_a_report_the_run_wrote_is_sent(self, mail_thunder, logger, report): + assert mail.send_report(report, not_before=time.time() - 1) is None + assert len(FakeSmtp.instances[0].sent) == 1 + + def test_a_folder_in_its_place_is_not_sent(self, mail_thunder, logger, tmp_path): + folder = tmp_path / "default_name.html" + folder.mkdir() + + assert mail.send_report(str(folder)) == "default_name.html is not a file" + + +class TestTheAnswer: + """What send_report says, for the run window: never a path, address or server reply.""" + + def test_sent(self, mail_thunder, logger, report): + assert mail.send_report(report) is None + + def test_no_user(self, mail_thunder, logger, report): + mail_thunder.read_output_content = lambda: {} + + assert mail.send_report(report) == "no mail user is set" + + def test_login_failed(self, mail_thunder, logger, report): + FakeSmtp.logs_in = False + + assert mail.send_report(report) == "the mail server login failed" + + def test_send_failed_names_only_the_error_kind(self, mail_thunder, logger, report): + FakeSmtp.send_error = ConnectionRefusedError("refused by mail.example.com for tester@example.com") + + reason = mail.send_report(report) + + assert reason == "sending failed (ConnectionRefusedError)" + + def test_without_je_mail_thunder(self, monkeypatch, logger, report): + monkeypatch.setitem(sys.modules, "je_mail_thunder", None) + + assert mail.send_report(report) == "je_mail_thunder is not installed" + + def test_the_answer_reaches_on_done(self, monkeypatch): + answered = threading.Event() + answers: list = [] + monkeypatch.setattr(mail, "send_report", lambda _path, *, not_before=None: "why") + + mail.send_after_test("report.html", on_done=lambda reason: (answers.append(reason), answered.set())) + + assert answered.wait(5) + assert answers == ["why"] + + def test_anything_raised_while_sending_still_reaches_on_done(self, monkeypatch, logger): + # The thread died with a traceback, and the run window never said a word + answered = threading.Event() + answers: list = [] + + def breaks(_path, *, not_before=None): + raise RuntimeError("inside je_mail_thunder") + + monkeypatch.setattr(mail, "send_report", breaks) + + mail.send_after_test("report.html", on_done=lambda reason: (answers.append(reason), answered.set())) + + assert answered.wait(5) + assert answers == ["sending failed (RuntimeError)"] + assert "inside je_mail_thunder" in _logged(logger) + + +class TestAMailSettingsFileThatCannotBeRead: + """je_mail_thunder reads mail_thunder_content.json unguarded, in the locale's encoding.""" + + @pytest.mark.parametrize("error", [ + ValueError("Expecting value"), + UnicodeDecodeError("cp950", b"\xe5", 0, 1, "illegal multibyte sequence"), + OSError("locked"), + ]) + def test_it_is_reported_and_nothing_is_sent(self, mail_thunder, logger, report, error): + def unreadable(): + raise error + + mail_thunder.read_output_content = unreadable + + assert mail.send_report(report) == ( + "the mail settings file (mail_thunder_content.json) could not be read") + assert FakeSmtp.instances == [] + assert "could not be read" in _logged(logger) diff --git a/test/test_utils/test_main_window_close_guard.py b/test/test_utils/test_main_window_close_guard.py new file mode 100644 index 00000000..00afcbbf --- /dev/null +++ b/test/test_utils/test_main_window_close_guard.py @@ -0,0 +1,59 @@ +"""Closing the IDE: one tab, dock or run window that raises on close does not stop the rest.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget, QWidget + +from pybreeze.pybreeze_ui.editor_main import main_ui +from pybreeze.pybreeze_ui.editor_main.main_ui import PyBreezeMainWindow, _close_guarded + + +class Refusing(QWidget): + def close(self) -> bool: + raise RuntimeError("a third-party tab that fails to close") + + +class Recording(QWidget): + def __init__(self, closed: list) -> None: + super().__init__() + self._closed = closed + + def close(self) -> bool: + self._closed.append(self) + return super().close() + + +def test_a_failing_step_is_logged_and_the_next_still_runs(monkeypatch): + QApplication.instance() or QApplication([]) + logged: list = [] + monkeypatch.setattr(main_ui.pybreeze_logger, "error", lambda *args: logged.append(args)) + ran: list = [] + + def fail(): + raise OSError("the run's child could not be stopped") + + _close_guarded(QWidget(), fail, lambda: ran.append("close")) + + assert ran == ["close"] + assert logged + + +def test_every_other_tool_tab_is_still_closed(monkeypatch): + QApplication.instance() or QApplication([]) + monkeypatch.setattr(main_ui.pybreeze_logger, "error", lambda *args: None) + window = QMainWindow() + window.tab_widget = QTabWidget() + closed: list = [] + before, after = Recording(closed), Recording(closed) + for tab in (before, Refusing(), after): + window.tab_widget.addTab(tab, "tool") + + # It used to raise here, and JEditor's own close -- open files, settings, + # auto-save threads -- never ran. + PyBreezeMainWindow._close_tool_tabs_and_docks(window) + + assert before in closed and after in closed + window.deleteLater() diff --git a/test/test_utils/test_mermaid_parser.py b/test/test_utils/test_mermaid_parser.py index 6f5a3738..0e68bbdf 100644 --- a/test/test_utils/test_mermaid_parser.py +++ b/test/test_utils/test_mermaid_parser.py @@ -91,6 +91,13 @@ def test_pipe_label(self): r = parse_mermaid("graph TD\nA-->|yes|B") assert r["connections"][0]["label"] == "yes" + @pytest.mark.parametrize("line", ['A-->|"a|b"|B', 'A-->| "a|b" |B']) + def test_a_quoted_pipe_label_keeps_its_bar(self, line): + # The label stopped at the "|" inside the quotes: '"a' + r = parse_mermaid(f"graph TD\n{line}") + assert [c["label"] for c in r["connections"]] == ["a|b"] + assert len(r["nodes"]) == 2 + def test_inline_label(self): r = parse_mermaid("graph TD\nA -- maybe --> B") assert r["connections"][0]["label"] == "maybe" @@ -284,3 +291,93 @@ def test_complex_graph_has_no_crossings(self): def test_tree_has_no_crossings(self): r = parse_mermaid("graph TD\nR-->A\nR-->B\nA-->X\nA-->Y\nB-->Z") assert _count_crossings(r) == 0 + + +def _node_texts(result) -> list: + return [node["text"] for node in result["nodes"]] + + +def _edges(result): + texts = [node["text"] for node in result["nodes"]] + return [(texts[c["source"]], texts[c["target"]], c["label"]) for c in result["connections"]] + + +class TestTextThatLooksLikeSyntax: + @pytest.mark.parametrize("label", ["a --> b", "a;b", "x -- y --> z", "p ~~~ q"]) + def test_an_arrow_or_semicolon_inside_a_quoted_label_is_label(self, label): + result = parse_mermaid(f'graph TD\nA["{label}"]-->B') + + assert _node_texts(result) == [label, "B"] + assert _edges(result) == [(label, "B", "")] + + def test_a_bracket_inside_quotes_does_not_end_the_label(self): + result = parse_mermaid('graph TD\nA["list[0]; done"] --> B') + + assert _node_texts(result) == ["list[0]; done", "B"] + + +class TestHeadersAndDirectives: + @pytest.mark.parametrize("header", ["graph", "flowchart", "Flowchart"]) + def test_a_header_without_a_direction_is_not_a_node(self, header): + result = parse_mermaid(f"{header}\nA-->B") + + assert _node_texts(result) == ["A", "B"] + + def test_direction_inside_a_subgraph_is_not_a_node(self): + result = parse_mermaid("graph LR\nsubgraph S\ndirection TB\nA-->B\nend") + + assert _node_texts(result) == ["A", "B"] + + def test_a_node_whose_name_starts_like_a_keyword_is_still_a_node(self): + assert "graphics" in _node_texts(parse_mermaid("graph TD\ngraphics-->directions")) + + +class TestMoreLinks: + @pytest.mark.parametrize("link", ["-- text ---", "-- text --o", "-- text --x", "-- text -->"]) + def test_text_on_any_normal_link_is_its_label(self, link): + result = parse_mermaid(f"graph TD\nA {link} B") + + assert _node_texts(result) == ["A", "B"] + assert _edges(result) == [("A", "B", "text")] + + def test_an_invisible_link_keeps_both_nodes_and_draws_nothing(self): + result = parse_mermaid("graph TD\nA ~~~ B") + + assert _node_texts(result) == ["A", "B"] + assert result["connections"] == [] + + +class TestWhatTheReviewFound: + def test_capitalised_keywords_are_nodes(self): + # "End", "Click", "Style" were taken for keywords and their lines skipped + result = parse_mermaid("graph TD\nStart --> End\nEnd((Finish))\nEnd --> C\nStyle[Style guide] --> Click") + + assert _node_texts(result) == ["Start", "Finish", "C", "Style guide", "Click"] + assert ("Start", "Finish", "") in _edges(result) + assert ("Finish", "C", "") in _edges(result) + + def test_lower_case_keywords_are_still_skipped(self): + result = parse_mermaid("graph TD\nsubgraph one\nA --> B\nend\nstyle A fill:#f9f\nclick A callback") + + assert _node_texts(result) == ["A", "B"] + + def test_a_class_suffix_keeps_the_label(self): + # "A[Label]:::hot" became a node labelled "A" + assert _node_texts(parse_mermaid("graph TD\nA[Label]:::hot --> B")) == ["Label", "B"] + assert _node_texts(parse_mermaid("graph TD\nA[Label] --> B\nA:::hot")) == ["Label", "B"] + + def test_a_chain_of_open_links_keeps_its_middle_node(self): + # The last two dashes of "---" were read as the start of an edge label + result = parse_mermaid("graph TD\nA --- B --- C") + + assert _node_texts(result) == ["A", "B", "C"] + assert _edges(result) == [("A", "B", ""), ("B", "C", "")] + + def test_percent_signs_in_a_label_are_not_a_comment(self): + result = parse_mermaid('graph TD\nA["100%% done"] --> B %% a real comment\n%% a whole-line comment') + + assert _node_texts(result) == ["100%% done", "B"] + assert _edges(result) == [("100%% done", "B", "")] + + def test_a_quoted_edge_label_loses_its_quotes(self): + assert _edges(parse_mermaid('graph TD\nA -->|"quoted label"| B')) == [("A", "B", "quoted label")] diff --git a/test/test_utils/test_message_boxes_show_text.py b/test/test_utils/test_message_boxes_show_text.py new file mode 100644 index 00000000..36b1b42a --- /dev/null +++ b/test/test_utils/test_message_boxes_show_text.py @@ -0,0 +1,66 @@ +"""A message box shows a file name, a path, a host or an error as text, never as markup. + +Qt reads markup in a message box's text (``Qt::AutoText``) and loads an +```` in it. Text that comes from a server or a file -- a reason phrase, a +file name, an ``OSError``'s message -- reached the boxes as it was. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pybreeze + +_STATIC_BOXES = {"warning", "critical", "information", "question", "about"} +_BOX_TEXT_SETTERS = {"setText", "setInformativeText", "setDetailedText"} + + +def _is_plain(node: ast.AST) -> bool: + """A literal, a word-dict entry as it is, or text already passed through ``as_text``.""" + if isinstance(node, ast.Constant): + return True + if not isinstance(node, ast.Call): + return False + func = node.func + if isinstance(func, ast.Name): + return func.id in ("as_text", "_lang") + return isinstance(func, ast.Attribute) and func.attr == "get" + + +def _box_names(tree: ast.AST) -> set[str]: + """What a ``QMessageBox(...)`` is assigned to: ``msg`` was not seen as a box.""" + names = set() + for node in ast.walk(tree): + if (isinstance(node, ast.Assign) and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Name) and node.value.func.id == "QMessageBox"): + names.update(ast.unparse(target) for target in node.targets) + return names + + +def _box_texts(tree: ast.AST): + boxes = _box_names(tree) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + func = node.func + receiver = ast.unparse(func.value) + if (func.attr in _STATIC_BOXES and isinstance(func.value, ast.Name) + and func.value.id == "QMessageBox" and len(node.args) >= 3): + yield node.args[2] + elif func.attr in _BOX_TEXT_SETTERS and node.args and (receiver in boxes or "box" in receiver.lower()): + yield node.args[0] + + +def test_a_box_held_by_any_name_is_checked(): + tree = ast.parse("msg = QMessageBox(parent)\nmsg.setText(reason)\n") + + assert [ast.unparse(text) for text in _box_texts(tree)] == ["reason"] + + +def test_every_message_box_text_is_plain_or_goes_through_as_text(): + unsafe = [] + for path in Path(pybreeze.__file__).parent.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8")) + unsafe += [f"{path.name}:{text.lineno}: {ast.unparse(text)[:80]}" + for text in _box_texts(tree) if not _is_plain(text)] + assert not unsafe, "Message box text not passed through as_text():\n" + "\n".join(unsafe) diff --git a/test/test_utils/test_no_blind_except.py b/test/test_utils/test_no_blind_except.py new file mode 100644 index 00000000..231dadb6 --- /dev/null +++ b/test/test_utils/test_no_blind_except.py @@ -0,0 +1,43 @@ +"""``except Exception`` only to log and re-raise, or with a reason on the line. + +CLAUDE.md, Code quality gates: a blind catch hides the error it was not written +for. The few places that must survive anything (a third-party tab, a run's +done-hook, an export) say why with ``# noqa: BLE001 — ``. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +PACKAGE = Path(__file__).resolve().parents[2] / "pybreeze" + + +def _catches_everything(handler: ast.ExceptHandler) -> bool: + caught = handler.type + if caught is None: + return True + names = caught.elts if isinstance(caught, ast.Tuple) else [caught] + return any(isinstance(name, ast.Name) and name.id in ("Exception", "BaseException") for name in names) + + +def _re_raises(handler: ast.ExceptHandler) -> bool: + return any(isinstance(node, ast.Raise) for node in ast.walk(handler)) + + +def _has_reason(line: str) -> bool: + _, _, comment = line.partition("# noqa: BLE001") + return bool(comment.strip(" —-:")) + + +def test_every_blind_catch_re_raises_or_says_why(): + offenders = [] + for path in sorted(PACKAGE.rglob("*.py")): + source = path.read_text(encoding="utf-8") + lines = source.splitlines() + for node in ast.walk(ast.parse(source, filename=str(path))): + if not isinstance(node, ast.ExceptHandler) or not _catches_everything(node): + continue + if _re_raises(node) or _has_reason(lines[node.lineno - 1]): + continue + offenders.append(f"{path.relative_to(PACKAGE.parent)}:{node.lineno}") + assert offenders == [] diff --git a/test/test_utils/test_output_actions.py b/test/test_utils/test_output_actions.py index 7504d244..432b7343 100644 --- a/test/test_utils/test_output_actions.py +++ b/test/test_utils/test_output_actions.py @@ -72,7 +72,7 @@ def test_opens_tab_with_text(self, app): parent, output, actions = _make(app, main_window=window) output.setPlainText("some code") with patch( - "je_editor.pyside_ui.main_ui.editor.editor_widget.EditorWidget", _FakeEditor + "je_editor.EditorWidget", _FakeEditor ): editor = actions.open_in_editor() assert editor.code_edit.text == "some code" @@ -131,6 +131,23 @@ def test_empty_output_is_noop(self, app): assert actions.save_to_file() is None parent.deleteLater() + def test_a_failed_write_is_reported(self, app, tmp_path): + parent, output, actions = _make(app) + output.setPlainText("content") + target = tmp_path / "no-such-folder" / "out.txt" + warned: list = [] + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), ""), + ), patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QMessageBox.warning", + side_effect=lambda *args: warned.append(args), + ): + assert actions.save_to_file() is None + # It went to the log only, and the user took the file as saved. + assert warned and "out.txt" in warned[0][2] + parent.deleteLater() + class TestSuggestedFilename: def test_static(self, app): @@ -143,3 +160,63 @@ def test_callable(self, app): app, basename=lambda: "dyn", extension=lambda: "py") assert actions.suggested_filename() == "dyn.py" parent.deleteLater() + + +class TestTheTextIsWhatWasGenerated: + """Copy, Save and Open in editor read the output as written, not Qt's display copy.""" + + # A no-break space and a line separator: toPlainText() turned them into a + # space and a newline, and a saved script no longer parsed + _GENERATED = 'data = "x=1\u00a0y\u2028z"\n' + + def test_copy_keeps_every_character(self, app): + parent, output, actions = _make(app) + output.setPlainText(self._GENERATED) + + actions.copy() + + assert QApplication.clipboard().text() == self._GENERATED + parent.deleteLater() + + def test_a_saved_file_keeps_every_character(self, app, tmp_path): + import ast + + parent, output, actions = _make(app, basename="request", extension="py") + output.setPlainText(self._GENERATED) + target = tmp_path / "request.py" + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), "Python (*.py)"), + ): + actions.save_to_file() + + saved = target.read_text(encoding="utf-8") + assert saved == self._GENERATED + ast.parse(saved) + parent.deleteLater() + + +def test_a_save_that_fails_leaves_the_file_it_was_replacing(app, tmp_path): + # write_text emptied the chosen file first; a failure part-way lost it + from pybreeze.utils.file_process import replace_file + + parent, output, actions = _make(app) + output.setPlainText("new content") + target = tmp_path / "keep.txt" + target.write_text("the user's file", encoding="utf-8") + + def refuse(*_args): + raise OSError(28, "No space left on device") + + with patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QFileDialog.getSaveFileName", + return_value=(str(target), ""), + ), patch( + "pybreeze.pybreeze_ui.tools_gui.output_actions.QMessageBox.warning", + side_effect=lambda *args: None, + ), patch.object(replace_file.os, "replace", side_effect=refuse): + assert actions.save_to_file() is None + + assert target.read_text(encoding="utf-8") == "the user's file" + assert list(tmp_path.iterdir()) == [target] + parent.deleteLater() diff --git a/test/test_utils/test_package_facade.py b/test/test_utils/test_package_facade.py new file mode 100644 index 00000000..226cc9bb --- /dev/null +++ b/test/test_utils/test_package_facade.py @@ -0,0 +1,72 @@ +"""The package facade: the public names, and what importing a utility costs. + +Importing ``pybreeze`` used to import the whole IDE, so ``pybreeze.utils`` came +with PySide6 and JEditor attached. The facade looks its names up on first use. +Each check runs in a fresh interpreter, since this one has long imported the IDE. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +_TIMEOUT_SECONDS = 120 + + +def _run(code: str) -> dict: + environment = { + **os.environ, + "QT_QPA_PLATFORM": "offscreen", + "PYTHONPATH": os.pathsep.join( + filter(None, [str(_REPOSITORY_ROOT), os.environ.get("PYTHONPATH")])), + } + completed = subprocess.run( # noqa: S603 — fixed argv: this interpreter and a literal script + [sys.executable, "-c", code], env=environment, capture_output=True, + timeout=_TIMEOUT_SECONDS, check=False, shell=False) + assert completed.returncode == 0, completed.stderr.decode("utf-8", "replace")[-2000:] + return json.loads(completed.stdout.decode("utf-8").splitlines()[-1]) + + +def test_a_utility_imports_without_the_ide(): + seen = _run( + "import json, sys\n" + "import pybreeze.utils.regex_tools.regex_tester\n" + "print(json.dumps({'loaded': [m for m in ('PySide6', 'je_editor', " + "'pybreeze.pybreeze_ui.editor_main.main_ui') if m in sys.modules]}))\n") + + assert seen["loaded"] == [] + + +def test_the_public_names_are_the_ide_s_own_objects(): + seen = _run( + "import json\n" + "import pybreeze\n" + "from pybreeze import EDITOR_EXTEND_TAB, PyBreezeMainWindow, start_editor\n" + "from pybreeze import load_external_plugins, register_natural_language, " + "register_programming_language\n" + "from pybreeze.pybreeze_ui.editor_main import main_ui\n" + "import je_editor\n" + "print(json.dumps({\n" + " 'same_registry': EDITOR_EXTEND_TAB is main_ui.EDITOR_EXTEND_TAB,\n" + " 'same_window': PyBreezeMainWindow is main_ui.PyBreezeMainWindow,\n" + " 'same_start': start_editor is main_ui.start_editor,\n" + " 'same_plugins': load_external_plugins is je_editor.load_external_plugins,\n" + " 'all_resolve': all(hasattr(pybreeze, name) for name in pybreeze.__all__),\n" + "}))\n") + + assert all(seen.values()), seen + + +def test_an_unknown_name_is_an_attribute_error(): + seen = _run( + "import json\n" + "import pybreeze\n" + "try:\n" + " pybreeze.no_such_name\n" + "except AttributeError:\n" + " print(json.dumps({'raised': True}))\n") + + assert seen["raised"] diff --git a/test/test_utils/test_plain_text.py b/test/test_utils/test_plain_text.py new file mode 100644 index 00000000..9c607e5f --- /dev/null +++ b/test/test_utils/test_plain_text.py @@ -0,0 +1,69 @@ +"""Text from a server or a file is shown as text, never as markup Qt would load.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtGui import QTextDocument +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.plain_text import as_text + +_HOSTILE = 'Listing failed: & click' + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.mark.parametrize("text", [_HOSTILE, "plain words", "two\nlines", "a < b && c > d"]) +def test_what_is_shown_is_the_text_itself(app, text): + document = QTextDocument() + document.setHtml(as_text(text)) + + assert document.toPlainText() == text + + +def test_a_failed_sftp_listing_shows_the_servers_text_as_text(app, monkeypatch): + # A hostile server's error went into the box as HTML: an with a UNC + # path made Windows connect out + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + shown: list = [] + monkeypatch.setattr(viewer.QMessageBox, "critical", staticmethod(lambda *args: shown.append(args[2]))) + tree = viewer.SSHFileTreeManager() + + tree._on_connect_failed('Indecipherable protocol version ""') + + assert "<img src=x>" in shown[0] and "x") + + assert "<b>" in shown[0] and "" not in shown[0] diff --git a/test/test_utils/test_plugin_menu.py b/test/test_utils/test_plugin_menu.py index 67612cc6..5b288667 100644 --- a/test/test_utils/test_plugin_menu.py +++ b/test/test_utils/test_plugin_menu.py @@ -12,7 +12,7 @@ import pytest from PySide6.QtWidgets import ( - QApplication, QMainWindow, QMenuBar, QTabWidget, QWidget + QApplication, QMainWindow, QMenuBar, QPlainTextEdit, QTabWidget, QWidget ) from pybreeze.extend_multi_language.update_language_dict import update_language_dict @@ -20,7 +20,7 @@ from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with from pybreeze.pybreeze_ui.menu.plugin_menu.build_plugin_menu import set_plugin_menu from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import ( - _get_current_file, _run_with, set_run_with_menu + run_config_suffixes, run_current_file_with, save_current_file_for_run, set_run_with_menu ) GO_CONFIG = {"name": "Go", "compiler": "go", "args": ("run",), "suffixes": (".go",)} @@ -111,14 +111,43 @@ def test_a_config_without_suffixes_is_labelled_by_name_alone( assert labels(window.run_with_menu) == ["Bare"] +class TestTheSuffixesAPluginRegistered: + @pytest.mark.parametrize(("registered", "expected"), [ + ((".go",), (".go",)), + ((".R",), (".r",)), + (("r",), (".r",)), + ((" .Py ", "py"), (".py",)), + (".rs", (".rs",)), + (("", ".", None, 3), ()), + ]) + def test_they_read_as_a_file_suffix_does(self, registered, expected): + assert run_config_suffixes({"suffixes": registered}) == expected + + def test_a_file_matching_a_suffix_registered_in_capitals_runs(self, window, tmp_path, monkeypatch): + script = tmp_path / "analysis.r" + script.touch() + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + refused: list = [] + monkeypatch.setattr(run_with.QMessageBox, "exec", lambda self: refused.append(self.text())) + started: list = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) + + run_current_file_with(window, {"name": "R", "compiler": "Rscript", "suffixes": (".R",)}) + + assert refused == [] + assert started + + class TestFindingTheFileToRun: def test_a_non_editor_tab_offers_no_file(self, window): window.tab_widget.addTab(QWidget(), "not an editor") window.tab_widget.setCurrentIndex(0) - assert _get_current_file(window) is None + assert save_current_file_for_run(window) is None def test_no_tabs_at_all_offers_no_file(self, window): - assert _get_current_file(window) is None + assert save_current_file_for_run(window) is None class TestRefusingToRunTheWrongFile: @@ -126,7 +155,7 @@ def test_a_suffix_mismatch_warns_and_runs_nothing( self, window, tmp_path, monkeypatch): script = tmp_path / "script.py" script.touch() - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) shown: list[str] = [] monkeypatch.setattr( run_with.QMessageBox, "exec", lambda self: shown.append(self.text())) @@ -135,24 +164,24 @@ def test_a_suffix_mismatch_warns_and_runs_nothing( run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) - _run_with(window, GO_CONFIG) + run_current_file_with(window, GO_CONFIG) assert shown and ".py" in shown[0] assert not started def test_no_file_means_nothing_runs(self, window, monkeypatch): - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: None) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: None) started: list[object] = [] monkeypatch.setattr( run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) - _run_with(window, GO_CONFIG) + run_current_file_with(window, GO_CONFIG) assert not started def test_a_matching_suffix_opens_a_run_window_and_starts( self, window, tmp_path, monkeypatch): script = tmp_path / "main.go" script.touch() - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) class Runner: def __init__(self, **kwargs) -> None: @@ -165,7 +194,7 @@ def run_file(self, config, path) -> None: started: list[Runner] = [] monkeypatch.setattr(run_with, "FileRunnerProcess", Runner) - _run_with(window, GO_CONFIG) + run_current_file_with(window, GO_CONFIG) assert len(started) == 1 assert started[0].ran == (GO_CONFIG, str(script)) @@ -175,12 +204,12 @@ def test_a_config_without_suffixes_accepts_any_file( self, window, tmp_path, monkeypatch): script = tmp_path / "anything.xyz" script.touch() - monkeypatch.setattr(run_with, "_get_current_file", lambda _w: str(script)) + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) started: list[object] = [] monkeypatch.setattr( run_with, "FileRunnerProcess", lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) - _run_with(window, {"name": "Anything", "compiler": "cat"}) + run_current_file_with(window, {"name": "Anything", "compiler": "cat"}) assert started @@ -209,7 +238,9 @@ def test_a_plugin_with_a_run_config_gets_a_submenu(self, window, monkeypatch): # About, a separator (empty text), then one run action for the suffix assert [text for text in submenus[0] if text] == ["About", "Run with Go"] - def test_multiple_suffixes_get_one_run_action_each(self, window, monkeypatch): + def test_multiple_suffixes_share_one_run_action_that_lists_them(self, window, monkeypatch): + # There was one entry per suffix, and every one ran the same config: + # the file's own suffix decides what runs, not the entry picked. monkeypatch.setattr( plugin_menu, "get_all_plugin_metadata", lambda: [{"name": "C++", "version": "1.0", "author": "someone", @@ -217,8 +248,8 @@ def test_multiple_suffixes_get_one_run_action_each(self, window, monkeypatch): set_plugin_menu(window) submenu = submenu_action_texts(window.plugin_menu)[0] runs = [text for text in submenu if "(" in text] - assert len(runs) == 2 - assert ".cpp" in runs[0] and ".hpp" in runs[1] + assert len(runs) == 1 + assert ".cpp, .hpp" in runs[0] def test_the_browser_entry_comes_first(self, window, monkeypatch): monkeypatch.setattr( @@ -231,16 +262,224 @@ def test_the_about_dialog_names_version_and_author(self, app, monkeypatch): shown: list[str] = [] monkeypatch.setattr( plugin_menu.QMessageBox, "exec", lambda self: shown.append(self.text())) - plugin_menu._make_about_callback("Go", "2.1", "someone")() + plugin_menu._make_about_callback(None, "Go", "2.1", "someone")() assert "2.1" in shown[0] assert "someone" in shown[0] + def test_the_about_dialog_shows_markup_as_text(self, app, monkeypatch): + # A plugin's name or author went to the box as markup, and all + from PySide6.QtGui import QTextDocument + + shown: list[str] = [] + monkeypatch.setattr( + plugin_menu.QMessageBox, "exec", lambda self: shown.append(self.text())) + plugin_menu._make_about_callback(None, "Go", "1", "")() + + document = QTextDocument() + document.setHtml(shown[0]) + assert "Go" in document.toPlainText() + assert "" in document.toPlainText() + def test_a_run_callback_ignores_a_non_editor_tab(self, window, monkeypatch): window.tab_widget.addTab(QWidget(), "not an editor") window.tab_widget.setCurrentIndex(0) started: list[object] = [] monkeypatch.setattr( - plugin_menu, "FileRunnerProcess", + run_with, "FileRunnerProcess", lambda **kwargs: started.append(kwargs)) plugin_menu._make_run_callback(window, GO_CONFIG)() assert not started + + def test_a_run_callback_runs_the_way_run_with_does(self, window, monkeypatch): + calls: list[tuple] = [] + monkeypatch.setattr( + plugin_menu, "run_current_file_with", + lambda main_window, config: calls.append((main_window, config))) + plugin_menu._make_run_callback(window, GO_CONFIG)() + assert calls == [(window, GO_CONFIG)] + + +class EditorTab(QWidget): + """Stands in for a JEditor editor tab: its file, its text and how it saves.""" + + def __init__(self, current_file=None, text="", file_encoding="utf-8", + line_ending="\n") -> None: + super().__init__() + self.current_file = current_file + self.code_edit = QPlainTextEdit(text) + self.file_encoding = file_encoding + self.line_ending = line_ending + self.events: list[str] = [] + + def mark_ignore_next_file_change(self) -> None: + self.events.append("expect write") + + def mark_saved(self) -> None: + self.events.append("saved") + + +@pytest.fixture() +def editor_tab(window, monkeypatch): + """Put an editor tab in the window; the helper recognises it by type.""" + monkeypatch.setattr(run_with, "EditorWidget", EditorTab) + + def make(**kwargs) -> EditorTab: + tab = EditorTab(**kwargs) + window.tab_widget.addTab(tab, "tab") + window.tab_widget.setCurrentWidget(tab) + return tab + return make + + +class TestSavingBeforeARun: + def test_a_saved_file_keeps_its_encoding_and_line_endings(self, window, editor_tab, tmp_path): + target = tmp_path / "legacy.txt" + target.write_bytes("舊\r\n".encode("big5")) + editor_tab(current_file=str(target), text="中文\n第二行\n", + file_encoding="big5", line_ending="\r\n") + + assert save_current_file_for_run(window) == str(target) + assert target.read_bytes() == "中文\r\n第二行\r\n".encode("big5") + + def test_the_tab_expects_the_write_and_is_marked_saved(self, window, editor_tab, tmp_path): + target = tmp_path / "main.go" + tab = editor_tab(current_file=str(target), text="package main\n") + + save_current_file_for_run(window) + + assert tab.events == ["expect write", "saved"] + assert target.read_bytes() == b"package main\n" + + def test_an_unnamed_tab_goes_through_save_as(self, window, editor_tab, tmp_path, monkeypatch): + tab = editor_tab(text="print(1)\n") + chosen = str(tmp_path / "chosen.py") + + def save_as(_main_window): + tab.current_file = chosen + return True + + monkeypatch.setattr(run_with, "choose_file_get_save_file_path", save_as) + assert save_current_file_for_run(window) == chosen + + def test_a_cancelled_save_as_runs_nothing(self, window, editor_tab, monkeypatch): + editor_tab(text="print(1)\n") + monkeypatch.setattr(run_with, "choose_file_get_save_file_path", lambda _w: False) + assert save_current_file_for_run(window) is None + + +class TestASaveThatFailsBeforeARun: + """A read-only or locked file, or a character the tab's encoding cannot hold.""" + + def test_it_is_reported_and_runs_nothing(self, window, editor_tab, tmp_path, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + warned: list = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: warned.append(a))) + target = tmp_path / "legacy.txt" + target.write_bytes("舊".encode("big5")) + tab = editor_tab(current_file=str(target), text="€ is not in Big5\n", file_encoding="big5") + + # It used to raise out of the menu slot, so nothing said why nothing ran. + assert save_current_file_for_run(window) is None + assert warned and "legacy.txt" in warned[0][2] + # ... and the tab still expects no write of its own, so the next real + # change made outside the editor is not swallowed. + assert tab.events == [] + + +class TestRunConfigsJEditorDoesNotCheck: + def test_a_config_without_a_name_still_runs(self, window, tmp_path, monkeypatch): + script = tmp_path / "main.go" + script.touch() + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + started: list[object] = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: type("R", (), {"run_file": lambda *a: started.append(a)})()) + + run_current_file_with(window, {"compiler": "go", "suffixes": (".go",)}) + + assert started + assert window.current_run_code_window[-1].windowTitle() == "Run - main.go" + + def test_a_config_without_a_compiler_is_reported_in_the_run_window(self, app): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + run_window = CodeWindow() + FileRunnerProcess(run_window).run_file({"name": "Broken"}, "main.go") + + assert "names no compiler" in run_window.code_result.toPlainText() + + +class TestAPluginThatDoesNotFollowTheRules: + """A plugin is third-party code: one bad plugin stopped the IDE from starting.""" + + def test_a_run_config_that_is_not_a_dict_is_skipped(self, window, monkeypatch): + # run_config_suffixes raised AttributeError while the main window was built + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": "Odd", "run_config": ["not", "a", "dict"]}, + {"name": "Go", "run_config": GO_CONFIG}]) + set_plugin_menu(window) + + assert "Odd" in labels(window.plugin_menu) + assert [text for text in submenu_action_texts(window.plugin_menu)[0] if text] == ["About", "Run with Go"] + + def test_a_name_that_is_none_gets_a_label(self, window, monkeypatch): + # addMenu(None) is an access violation, not a Python error + monkeypatch.setattr( + plugin_menu, "get_all_plugin_metadata", + lambda: [{"name": None, "run_config": {"name": None, "suffixes": [".x"]}}, "not metadata"]) + set_plugin_menu(window) + + assert submenu_action_texts(window.plugin_menu) + + def test_run_configs_with_none_and_text_names_sort(self, window, monkeypatch): + # sorted() raised comparing None with a string + monkeypatch.setattr( + run_with, "get_all_plugin_run_configs", + lambda: [{"name": "Go", "suffixes": [".go"]}, {"name": None}, "not a config"]) + set_run_with_menu(window) + + assert labels(window.run_with_menu) == ["Go (.go)", "Unknown"] + + +class TestTheEncodingAProgramWrites: + """Java and localized compilers write the console code page, not UTF-8.""" + + def test_the_ides_encoding_by_default(self): + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"name": "Go"}, "utf-8") == "utf-8" + + def test_a_config_can_name_its_own(self): + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"name": "Java", "encoding": "MS950"}, "utf-8") == "cp950" + + def test_locale_means_this_machines(self): + import locale + + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"encoding": "locale"}, "utf-8") == locale.getpreferredencoding(False) + + @pytest.mark.parametrize("named", ["no-such-codec", 5, " "]) + def test_anything_else_keeps_the_default(self, named): + from pybreeze.pybreeze_ui.menu.plugin_menu.build_run_with_menu import output_encoding + + assert output_encoding({"name": "Odd", "encoding": named}, "utf-8") == "utf-8" + + def test_the_run_decodes_with_it(self, window, monkeypatch, tmp_path): + script = tmp_path / "anything.xyz" + script.touch() + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + made: list = [] + monkeypatch.setattr( + run_with, "FileRunnerProcess", + lambda **kwargs: made.append(kwargs) or type("R", (), {"run_file": lambda *a: None})()) + + run_current_file_with(window, {"name": "Java", "compiler": "java", "encoding": "cp950"}) + + assert made[0]["program_encoding"] == "cp950" diff --git a/test/test_utils/test_prompt_store.py b/test/test_utils/test_prompt_store.py index 71ea936e..4d73ab92 100644 --- a/test/test_utils/test_prompt_store.py +++ b/test/test_utils/test_prompt_store.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -22,7 +23,7 @@ @pytest.fixture() def prompts(tmp_path, monkeypatch): """Point the prompt directory at a temporary one, never the real home.""" - monkeypatch.setattr(prompt_store, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) return tmp_path / "prompts" @@ -37,6 +38,34 @@ def test_looking_at_a_prompt_creates_no_directory(self, prompts): load_prompt("linter.md", "built-in") assert not prompt_dir().exists() + def test_looking_at_a_prompt_does_not_create_the_data_directory_either(self, tmp_path, monkeypatch): + data = tmp_path / "home" / ".pybreeze" + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: data) + + assert load_prompt("linter.md", "built-in") == "built-in" + assert not data.exists() + + def test_a_file_where_the_data_directory_should_be_gives_the_built_in(self, tmp_path, monkeypatch): + # Making the directory raised FileExistsError on the review's worker + # thread, and the review stopped with nothing in the panel. + data = tmp_path / ".pybreeze" + data.write_text("not a folder", encoding="utf-8") + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: data) + + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_a_prompt_folder_that_cannot_be_looked_into_gives_the_built_in(self, prompts, monkeypatch): + looked_up = prompt_store.Path.is_file + + def refuse(path) -> bool: + if path.name == "linter.md": + raise PermissionError(13, "Access is denied") + return looked_up(path) + + monkeypatch.setattr(prompt_store.Path, "is_file", refuse) + + assert load_prompt("linter.md", "built-in") == "built-in" + def test_saving_creates_the_directory(self, prompts, tmp_path): from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.prompt_file_io import ( save_prompt_text @@ -119,7 +148,7 @@ def test_every_template_can_be_overridden(self, prompts): class TestTheEditorAndTheChainAgree: """The point of the editor: what is saved there is what a review sends.""" - def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts): + def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts, monkeypatch): from PySide6.QtWidgets import QApplication, QMessageBox from pybreeze.extend_multi_language.update_language_dict import update_language_dict @@ -131,7 +160,7 @@ def test_saving_in_the_editor_changes_what_the_chain_sends(self, prompts): update_language_dict() editor = CoTPromptEditor() # The confirmation dialogs would block; the save itself is what matters. - QMessageBox.information = staticmethod(lambda *a, **k: None) + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: None)) editor.file_selector.setCurrentIndex( editor.prompt_files.index("linter.md")) @@ -201,3 +230,304 @@ def test_an_edited_skill_is_what_the_selector_loads(self, prompts): widget.prompt_select.setCurrentText(name) assert widget.prompt_input.toPlainText() == "my own skill prompt" widget.deleteLater() + + +def _cot_editor(monkeypatch): + """A CoT prompt editor whose message boxes answer without blocking.""" + from PySide6.QtWidgets import QApplication, QMessageBox + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import ( + CoTPromptEditor + ) + + QApplication.instance() or QApplication([]) + update_language_dict() + shown: list = [] + monkeypatch.setattr(QMessageBox, "information", staticmethod(lambda *a, **k: shown.append(a))) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: shown.append(a))) + editor = CoTPromptEditor() + editor.shown = shown + return editor + + +class TestAPromptFileInAnotherEncoding: + """A prompt saved as "ANSI" on Windows is not UTF-8; it must not break anything.""" + + def test_the_review_falls_back_to_the_built_in(self, prompts): + prompts.mkdir(parents=True, exist_ok=True) + (prompts / "linter.md").write_bytes("檢查這段程式碼".encode("cp950")) + + assert load_prompt("linter.md", "built-in") == "built-in" + + def test_a_byte_order_mark_does_not_reach_the_prompt(self, prompts): + prompts.mkdir(parents=True, exist_ok=True) + (prompts / "linter.md").write_text("my prompt", encoding="utf-8-sig") + + assert load_prompt("linter.md", "built-in") == "my prompt" + + def test_the_editor_opens_it_and_says_so(self, prompts, monkeypatch): + prompts.mkdir(parents=True, exist_ok=True) + (prompts / "linter.md").write_bytes("檢查這段程式碼".encode("cp950")) + editor = _cot_editor(monkeypatch) + + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + + assert editor.shown, "the user was not told the file is not UTF-8" + assert editor.current_file == str(prompt_path("linter.md")) + editor.close() + editor.deleteLater() + + +class TestAChangeOnDiskWhileEditing: + def test_unsaved_edits_are_kept_unless_the_user_says_otherwise(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + # Typed, the way a keystroke arrives: setPlainText() would reset the + # document's modified flag, which is what a real edit sets. + editor.middle_editor.selectAll() + editor.middle_editor.textCursor().insertText("typed here, not saved") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + write(prompts, "linter.md", "changed elsewhere") + editor.on_file_changed(editor.current_file) + + assert editor.middle_editor.toPlainText() == "typed here, not saved" + editor.close() + editor.deleteLater() + + def test_with_nothing_unsaved_it_reloads_without_asking(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + asked: list = [] + monkeypatch.setattr( + QMessageBox, "question", staticmethod(lambda *a, **k: asked.append(a))) + + write(prompts, "linter.md", "changed elsewhere") + editor.on_file_changed(editor.current_file) + + assert editor.middle_editor.toPlainText() == "changed elsewhere" + assert asked == [] + editor.close() + editor.deleteLater() + + +class TestSavingAPrompt: + def test_a_save_that_fails_leaves_the_last_good_file(self, prompts, monkeypatch): + from pybreeze.utils.file_process import replace_file + + write(prompts, "linter.md", "the last good prompt") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + editor.middle_editor.setPlainText("a new prompt") + + def refuse(*_args): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(replace_file.os, "replace", refuse) + editor.save_file() + + assert (prompts / "linter.md").read_text(encoding="utf-8") == "the last good prompt" + assert [path.name for path in prompts.iterdir()] == ["linter.md"] + editor.close() + editor.deleteLater() + + +class TestClosingThePromptEditor: + def test_its_file_watcher_goes_with_it(self, prompts, monkeypatch): + # An orphan watcher outlived the editor and delivered the next change on + # disk to a deleted widget -- a crash, once the slot touched the editor. + from PySide6.QtCore import QObject + + editor = _cot_editor(monkeypatch) + + assert editor.watcher.parent() is editor + assert isinstance(editor.watcher, QObject) + editor.close() + editor.deleteLater() + + def test_closing_it_stops_watching(self, prompts, monkeypatch): + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + + editor.close() + + assert editor.watcher.files() == [] + editor.deleteLater() + + +class TestATemplateWithNoFileYet: + def test_saving_it_does_not_write_the_does_not_exist_note(self, prompts, monkeypatch): + # The note used to be the edit area's text, so Save wrote it to disk and + # every review then sent "(File linter.md does not exist)" as the prompt. + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + + assert editor.middle_editor.toPlainText() == "" + assert "linter.md" in editor.middle_editor.placeholderText() + editor.save_file() + + assert load_prompt("linter.md", "built-in") == "built-in" + editor.close() + editor.deleteLater() + + +class TestSwitchingTemplatesWithUnsavedEdits: + @staticmethod + def _typed(editor, text): + editor.middle_editor.selectAll() + editor.middle_editor.textCursor().insertText(text) + + def test_keeping_the_edits_stays_on_the_template(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + linter = editor.prompt_files.index("linter.md") + editor.file_selector.setCurrentIndex(linter) + self._typed(editor, "MY EDITS") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + editor.file_selector.setCurrentIndex((linter + 1) % len(editor.prompt_files)) + + assert editor.file_selector.currentIndex() == linter + assert editor.middle_editor.toPlainText() == "MY EDITS" + assert editor.current_file == str(prompt_path("linter.md")) + editor.close() + editor.deleteLater() + + def test_letting_them_go_switches(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + editor = _cot_editor(monkeypatch) + linter = editor.prompt_files.index("linter.md") + editor.file_selector.setCurrentIndex(linter) + self._typed(editor, "MY EDITS") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)) + other = (linter + 1) % len(editor.prompt_files) + + editor.file_selector.setCurrentIndex(other) + + assert editor.current_file == str(prompt_path(editor.prompt_files[other])) + assert editor.middle_editor.toPlainText() != "MY EDITS" + editor.close() + editor.deleteLater() + + def test_reload_asks_before_losing_edits(self, prompts, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + write(prompts, "linter.md", "on disk") + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + self._typed(editor, "MY EDITS") + monkeypatch.setattr( + QMessageBox, "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + editor.reload_button.click() + + assert editor.middle_editor.toPlainText() == "MY EDITS" + editor.close() + editor.deleteLater() + + +class TestAnEditedPromptThatReachesIntoAPlaceholder: + @pytest.mark.parametrize("edited", [ + "Review {code_diff[x]}", # a TypeError that used to end the whole chain + "Review {code_diff.upper}", # quietly sent "" + "Review {code_diff[0]}", # quietly sent one character of the code + "Review {0}", + "Review {}", + ]) + def test_it_falls_back_to_the_built_in(self, prompts, edited): + write(prompts, "linter.md", edited) + + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + + assert CODE in prompt + assert "built-in method" not in prompt + + def test_escaped_braces_and_a_format_spec_still_work(self, prompts): + write(prompts, "linter.md", "{{literal}} then {code_diff!s:>1}") + + prompt = build_prompt("linter.md", {CODE_DIFF: CODE}) + + assert "{literal}" in prompt and CODE in prompt + + +def _prompt_editor(): + from PySide6.QtWidgets import QApplication + + from pybreeze.extend_multi_language.update_language_dict import update_language_dict + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import CoTPromptEditor + + QApplication.instance() or QApplication([]) + update_language_dict() + return CoTPromptEditor() + + +class TestThePromptEditorKeepsWhatWasTyped: + def test_create_asks_before_replacing_typed_text(self, prompts, monkeypatch): + # Create wrote the built-in template over it without asking + from PySide6.QtWidgets import QMessageBox + + editor = _prompt_editor() + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + editor.middle_editor.setPlainText("my own prompt") + editor.middle_editor.document().setModified(True) + asked: list = [] + monkeypatch.setattr(QMessageBox, "question", + staticmethod(lambda *args: asked.append(args[2]) or QMessageBox.StandardButton.No)) + + editor.create_file() + + assert asked + assert not (prompts / "linter.md").exists() + assert editor.middle_editor.toPlainText() == "my own prompt" + editor.deleteLater() + + def test_a_file_that_cannot_be_read_does_not_show_the_previous_text(self, prompts, monkeypatch): + # The previous template's text stayed on screen under this one's name + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui import prompt_editor_widget + + write(prompts, "linter.md", "the linter prompt") + editor = _prompt_editor() + editor.middle_editor.setPlainText("the previous template") + monkeypatch.setattr(prompt_editor_widget, "read_prompt_file", lambda _path: None) + + def locked(_path): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(Path, "read_bytes", locked) + editor.load_file_content(editor.prompt_files.index("linter.md")) + + assert editor.middle_editor.toPlainText() == "" + assert editor.current_file is None + assert "Permission denied" in editor.middle_editor.placeholderText() + editor.deleteLater() + + def test_saving_writes_back_the_characters_it_did_not_touch(self, prompts, monkeypatch): + # Saved from toPlainText(), a no-break space became a space and U+2028 a newline + original = "Review\xa0this:
{code_diff}\n" + write(prompts, "linter.md", original) + editor = _cot_editor(monkeypatch) + editor.file_selector.setCurrentIndex(editor.prompt_files.index("linter.md")) + + editor.save_file() + + assert (prompts / "linter.md").read_text(encoding="utf-8") == original + editor.close() + editor.deleteLater() + diff --git a/test/test_utils/test_prthinker_contract.py b/test/test_utils/test_prthinker_contract.py new file mode 100644 index 00000000..af5ea679 --- /dev/null +++ b/test/test_utils/test_prthinker_contract.py @@ -0,0 +1,178 @@ +"""PyBreeze's side of the prthinker command line, checked against the real prthinker. + +prthinker is not on PyPI and needs Python 3.12 or newer, so these run only where +an interpreter with prthinker installed is at hand: the one running the tests +(CI's 3.12 leg installs it), or one named by ``PYBREEZE_PRTHINKER_PYTHON``. + +Each case runs the real CLI with the arguments and the environment PyBreeze +builds, with the backend and the forge pointed at a closed local port. Reaching +the network (``ConnectError``) means prthinker accepted every argument and every +variable. A rename on either side stops it earlier instead, with +"unrecognized arguments", "is required" or "is not a valid". +""" +from __future__ import annotations + +import importlib.util +import json +import os +import socket +import subprocess +import sys + +import pytest + +from pybreeze.extend.prthinker_extend.prthinker_setting import ( + BACKENDS, DEFAULT_SETTING, PRTHINKER_PACKAGE, environment_for, review_file_arguments, + review_pr_arguments +) +from pybreeze.utils.subprocess_util import utf8_subprocess_env + +_RUN_TIMEOUT_SECONDS = 120 +_REJECTIONS = ("unrecognized arguments", "is required", "is not a valid", "invalid choice") + + +def _prthinker_python() -> str | None: + named = os.environ.get("PYBREEZE_PRTHINKER_PYTHON") + if named: + return named + if importlib.util.find_spec(PRTHINKER_PACKAGE) is not None: + return sys.executable + return None + + +PRTHINKER_PYTHON = _prthinker_python() +pytestmark = pytest.mark.skipif( + PRTHINKER_PYTHON is None, + reason="prthinker is not installed here (set PYBREEZE_PRTHINKER_PYTHON to run these)") + + +@pytest.fixture() +def closed_url() -> str: + """An address on this machine where nothing listens.""" + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + return f"http://127.0.0.1:{port}" + + +def _run(arguments: list[str], setting: dict, cwd) -> subprocess.CompletedProcess: + # The same environment TaskProcessManager gives the child. + environment = {**utf8_subprocess_env("utf-8"), **environment_for(setting)} + return subprocess.run( # noqa: S603 — fixed interpreter and argv built by the code under test + [PRTHINKER_PYTHON, "-m", PRTHINKER_PACKAGE, *arguments], + cwd=cwd, env=environment, stdin=subprocess.DEVNULL, capture_output=True, + timeout=_RUN_TIMEOUT_SECONDS, check=False, shell=False, + ) + + +def _assert_accepted(completed: subprocess.CompletedProcess) -> None: + errors = completed.stderr.decode("utf-8", "replace") + rejected = [phrase for phrase in _REJECTIONS if phrase in errors] + assert not rejected, f"prthinker rejected what PyBreeze sent ({rejected}):\n{errors[-2000:]}" + assert "ConnectError" in errors, f"prthinker stopped before the network:\n{errors[-2000:]}" + + +def test_every_backend_pybreeze_offers_is_one_prthinker_knows(): + completed = subprocess.run( # noqa: S603 — fixed interpreter and a literal script + [PRTHINKER_PYTHON, "-c", + "import json; from prthinker.config import BackendKind; " + "print(json.dumps([kind.value for kind in BackendKind]))"], + capture_output=True, timeout=_RUN_TIMEOUT_SECONDS, check=True, shell=False, + ) + known = set(json.loads(completed.stdout)) + assert set(BACKENDS) <= known, f"offered but unknown to prthinker: {set(BACKENDS) - known}" + + +@pytest.mark.parametrize("backend", ["openai", "remote"]) +def test_reviewing_a_file_is_accepted(tmp_path, closed_url, backend): + source = tmp_path / "main.py" + source.write_text("print(1)\n", encoding="utf-8") + setting = { + **DEFAULT_SETTING, + "backend": backend, + "model_name": "any-model", + "remote_url": closed_url, + "openai_base_url": f"{closed_url}/v1", + "openai_api_key": "not-a-real-key", + "extra_arguments": "--max-new-tokens 16", + } + _assert_accepted(_run(review_file_arguments(str(source), setting), setting, tmp_path)) + + +def test_rule_retrieval_through_the_server_is_accepted(tmp_path, closed_url): + # An installed prthinker has no local RAG index: were the variable that + # asks for the server's /rag not read, it would stop on that import instead. + source = tmp_path / "main.py" + source.write_text("print(1)\n", encoding="utf-8") + setting = { + **DEFAULT_SETTING, + "backend": "remote", + "remote_url": closed_url, + "rag": "remote", + "extra_arguments": "--max-new-tokens 16", + } + _assert_accepted(_run(review_file_arguments(str(source), setting), setting, tmp_path)) + + +# Build prthinker's review configuration the way its command line does, from the +# environment alone, and report the backend and the model it settled on. +# ``_build_parser`` and ``_build_config`` are in ``prthinker.cli.__all__``. +_REPORT_BACKEND_AND_MODEL = """ +import json +from prthinker.cli import _build_config, _build_parser +args = _build_parser().parse_args(["review-file", "main.py"]) +config = _build_config(args) +kind = getattr(config.backend, "value", config.backend) +chosen = getattr(config, kind.replace("-", "_")) +for attribute in ("model", "model_name"): + if hasattr(chosen, attribute): + model = getattr(chosen, attribute) + break +else: + # The remote server picks its own model; prthinker only reports this one. + model = args.model_name +print(json.dumps({"backend": kind, "model": model})) +""" + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_the_model_reaches_the_backend_it_was_set_for(closed_url, backend): + setting = { + **DEFAULT_SETTING, + "backend": backend, + "model_name": "pybreeze-contract-model", + "remote_url": closed_url, + "openai_api_key": "not-a-real-key", + "anthropic_api_key": "not-a-real-key", + } + environment = { + **utf8_subprocess_env("utf-8"), + # The three keys PyBreeze has no field for: prthinker reads them from + # the environment the IDE was started in. + "PRTHINKER_GEMINI_API_KEY": "not-a-real-key", + "PRTHINKER_COHERE_API_KEY": "not-a-real-key", + "PRTHINKER_MISTRAL_API_KEY": "not-a-real-key", + **environment_for(setting), + } + completed = subprocess.run( # noqa: S603 — fixed interpreter and a literal script + [PRTHINKER_PYTHON, "-c", _REPORT_BACKEND_AND_MODEL], + env=environment, capture_output=True, timeout=_RUN_TIMEOUT_SECONDS, check=False, + shell=False, + ) + assert completed.returncode == 0, completed.stderr.decode("utf-8", "replace")[-2000:] + assert json.loads(completed.stdout) == { + "backend": backend, "model": "pybreeze-contract-model"} + + +def test_reviewing_a_pull_request_is_accepted(tmp_path, closed_url): + setting = { + **DEFAULT_SETTING, + "backend": "openai", + "openai_base_url": f"{closed_url}/v1", + "openai_api_key": "not-a-real-key", + "platform": "github", + "platform_base_url": closed_url, + "repository": "owner/name", + "platform_token": "not-a-real-token", + } + _assert_accepted(_run(review_pr_arguments(12, setting), setting, tmp_path)) diff --git a/test/test_utils/test_prthinker_menu_review.py b/test/test_utils/test_prthinker_menu_review.py new file mode 100644 index 00000000..8b8902ea --- /dev/null +++ b/test/test_utils/test_prthinker_menu_review.py @@ -0,0 +1,59 @@ +"""prthinker's Review current file: the file is saved before the review reads it from disk.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget, QWidget + +from pybreeze.pybreeze_ui.menu.automation_menu.prthinker_menu import build_prthinker_menu as menu + + +class EditorTab(QWidget): + """Stands in for a JEditor editor tab.""" + + +@pytest.fixture() +def window(monkeypatch): + QApplication.instance() or QApplication([]) + monkeypatch.setattr(menu, "EditorWidget", EditorTab) + made = QMainWindow() + made.tab_widget = QTabWidget() + made.events = [] + monkeypatch.setattr(menu, "_tell", lambda _window, key: made.events.append(("told", key))) + monkeypatch.setattr( + menu, "review_current_file", lambda _window: made.events.append("reviewed") or True) + yield made + made.deleteLater() + + +def test_the_file_is_saved_before_it_is_reviewed(window, monkeypatch): + window.tab_widget.addTab(EditorTab(), "main.py") + monkeypatch.setattr( + menu, "save_current_file_for_run", lambda _window: window.events.append("saved") or "main.py") + + menu._review_current_file(window) + + # The review reads the file on disk; unsaved edits used to be left out. + assert window.events == ["saved", "reviewed"] + + +def test_a_save_that_did_not_happen_reviews_nothing(window, monkeypatch): + window.tab_widget.addTab(EditorTab(), "untitled") + monkeypatch.setattr(menu, "save_current_file_for_run", lambda _window: None) + + menu._review_current_file(window) + + # Cancelled, or failed and already reported: nothing more to say. + assert window.events == [] + + +def test_a_tab_that_is_not_an_editor_says_what_is_needed(window, monkeypatch): + window.tab_widget.addTab(QWidget(), "a tool tab") + monkeypatch.setattr(menu, "save_current_file_for_run", lambda _window: None) + + menu._review_current_file(window) + + assert window.events == [("told", "prthinker_need_saved_file_message")] diff --git a/test/test_utils/test_prthinker_process.py b/test/test_utils/test_prthinker_process.py index 7e5d877c..9f8e3b5c 100644 --- a/test/test_utils/test_prthinker_process.py +++ b/test/test_utils/test_prthinker_process.py @@ -3,6 +3,7 @@ import pytest +from pybreeze.extend.process_executor import process_executor_utils from pybreeze.extend.process_executor.prthinker import prthinker_process from pybreeze.extend.prthinker_extend.prthinker_setting import DEFAULT_SETTING @@ -22,6 +23,7 @@ def __init__(self, current_widget=None) -> None: self._current_widget = current_widget self.current_run_code_window: list = [] self.encoding = "utf-8" + self.python_compiler = None self.cleared = False def currentWidget(self): # noqa: N802 — the Qt name this stands in for @@ -31,6 +33,22 @@ def clear_code_result(self) -> None: self.cleared = True +class Signalled: + """The one signal a run window offers its main window.""" + + def connect(self, _slot) -> None: + """Nothing listens here: the stand-in is never closed.""" + + +class RunWindow: + """Stands in for the run window the review's output goes to.""" + + python_compiler = None + + def __init__(self) -> None: + self.finished_and_closed = Signalled() + + class RecordingProcess: """Stands in for the task process manager and remembers the call.""" @@ -47,8 +65,8 @@ def start_module_process(self, package, arguments, environment=None): def recorded(monkeypatch): """Catch the review before it reaches a real process or a real window.""" RecordingProcess.calls = [] - monkeypatch.setattr(prthinker_process, "TaskProcessManager", RecordingProcess) - monkeypatch.setattr(prthinker_process, "CodeWindow", lambda: object()) + monkeypatch.setattr(process_executor_utils, "TaskProcessManager", RecordingProcess) + monkeypatch.setattr(process_executor_utils, "CodeWindow", RunWindow) # The editor tab is recognised by its type, so the stand-in becomes that type. monkeypatch.setattr(prthinker_process, "EditorWidget", Editor) return RecordingProcess.calls diff --git a/test/test_utils/test_prthinker_setting.py b/test/test_utils/test_prthinker_setting.py index e807c1a1..80f1058a 100644 --- a/test/test_utils/test_prthinker_setting.py +++ b/test/test_utils/test_prthinker_setting.py @@ -2,21 +2,23 @@ from __future__ import annotations import json +import os import pytest from pybreeze.extend.prthinker_extend import prthinker_setting from pybreeze.extend.prthinker_extend.prthinker_setting import ( - DEFAULT_SETTING, INSTALL_EXTRAS, SECRET_SETTINGS, SETTING_ENVIRONMENT, + BACKENDS, DEFAULT_SETTING, INSTALL_EXTRAS, MODEL_ENVIRONMENT, RAG_MODES, SECRET_SETTINGS, SETTING_FILE_NAME, + SETTING_ENVIRONMENT, environment_for, extra_arguments, install_target, load_setting, loggable, - review_file_arguments, review_pr_arguments, save_setting + review_file_arguments, review_pr_arguments, save_setting, setting_path, split_arguments ) @pytest.fixture() def data_dir(tmp_path, monkeypatch): """Keep every test's settings file inside its own temporary directory.""" - monkeypatch.setattr(prthinker_setting, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: tmp_path) return tmp_path @@ -40,6 +42,19 @@ def test_an_unknown_field_is_ignored(self, data_dir): json.dumps({"nonsense": "value"}), encoding="utf-8") assert "nonsense" not in load_setting() + @pytest.mark.parametrize("value", [None, 5, True, ["--a", "--b"], {"k": "v"}]) + def test_a_value_that_is_not_text_keeps_its_default(self, data_dir, value): + # str() made null the text "None", sent on as the API key and model. + (data_dir / "prthinker_setting.json").write_text(json.dumps({ + "openai_api_key": value, "extra_arguments": value, "repository": "owner/name", + }), encoding="utf-8") + + setting = load_setting() + + assert setting["openai_api_key"] == DEFAULT_SETTING["openai_api_key"] + assert setting["extra_arguments"] == DEFAULT_SETTING["extra_arguments"] + assert setting["repository"] == "owner/name" + def test_a_broken_file_does_not_stop_the_feature(self, data_dir): (data_dir / "prthinker_setting.json").write_text("{ not json", encoding="utf-8") assert load_setting() == DEFAULT_SETTING @@ -47,8 +62,57 @@ def test_a_broken_file_does_not_stop_the_feature(self, data_dir): def test_saving_reports_failure_instead_of_raising(self, data_dir, monkeypatch): def refuse(*_args, **_kwargs): raise OSError("read-only") - monkeypatch.setattr( - prthinker_setting.Path, "write_text", refuse, raising=False) + monkeypatch.setattr(prthinker_setting, "replace_text", refuse) + assert save_setting(DEFAULT_SETTING) is False + + def test_a_save_that_fails_part_way_keeps_the_stored_keys(self, data_dir, monkeypatch): + # Written in place, the file was emptied first: a full disk lost every + # key and token in it, and the next save stored the defaults + from pybreeze.utils.file_process import replace_file + + save_setting({**DEFAULT_SETTING, "openai_api_key": "kept-key"}) + + def disk_full(*_args, **_kwargs): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(replace_file.os, "replace", disk_full) + + assert save_setting({**DEFAULT_SETTING, "openai_api_key": "new-key"}) is False + assert load_setting()["openai_api_key"] == "kept-key" + assert [path.name for path in setting_path().parent.iterdir()] == [SETTING_FILE_NAME] + + def test_a_lone_surrogate_loaded_from_the_file_fails_the_save_and_keeps_it(self, data_dir): + # The file may hold "\ud800" escaped; UTF-8 cannot write it back, and + # the error escaped the install menu's slot + (data_dir / SETTING_FILE_NAME).write_text('{"repository": "a\\ud800"}', encoding="utf-8") + before = (data_dir / SETTING_FILE_NAME).read_bytes() + + assert save_setting(load_setting()) is False + assert (data_dir / SETTING_FILE_NAME).read_bytes() == before + assert [path.name for path in data_dir.iterdir()] == [SETTING_FILE_NAME] + + def test_the_file_is_created_for_its_owner_only(self, data_dir, monkeypatch): + from pybreeze.utils.file_process import replace_file + + modes: list = [] + opened = replace_file.os.open + + def record(path, flags, mode=0o777): + modes.append(mode) + return opened(path, flags, mode) + + monkeypatch.setattr(replace_file.os, "open", record) + + assert save_setting(DEFAULT_SETTING) is True + assert modes == [0o600] + + def test_reading_creates_nothing_and_a_file_for_the_folder_reads_as_defaults(self, tmp_path, monkeypatch): + # Making the folder raised FileExistsError out of every prthinker entry + blocked = tmp_path / ".pybreeze" + blocked.write_text("not a folder", encoding="utf-8") + monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: blocked) + + assert load_setting() == DEFAULT_SETTING assert save_setting(DEFAULT_SETTING) is False @@ -70,6 +134,76 @@ def test_every_setting_that_travels_has_a_variable(self): assert set(SETTING_ENVIRONMENT) <= set(DEFAULT_SETTING) +class TestTheModelName: + # prthinker reads the model from a different variable for each backend. + @pytest.mark.parametrize("backend,variable", [ + ("remote", "PRTHINKER_MODEL_NAME"), + ("local", "PRTHINKER_MODEL_NAME"), + ("openai", "PRTHINKER_OPENAI_MODEL"), + ("anthropic", "PRTHINKER_ANTHROPIC_MODEL"), + ("gemini", "PRTHINKER_GEMINI_MODEL"), + ("cohere", "PRTHINKER_COHERE_MODEL"), + ("mistral", "PRTHINKER_MISTRAL_MODEL"), + ("claude-cli", "PRTHINKER_CLAUDE_CLI_MODEL"), + ("codex-cli", "PRTHINKER_CODEX_CLI_MODEL"), + ]) + def test_it_goes_to_the_chosen_backends_variable(self, backend, variable): + environment = environment_for( + {**DEFAULT_SETTING, "backend": backend, "model_name": " the-model "}) + model_variables = {name for name in environment if name.endswith(("_MODEL", "_MODEL_NAME"))} + assert model_variables == {variable} + assert environment[variable] == "the-model" + + def test_every_backend_on_offer_has_a_model_variable(self): + assert set(MODEL_ENVIRONMENT) == set(BACKENDS) + + def test_no_model_leaves_every_backend_its_default(self): + environment = environment_for({**DEFAULT_SETTING, "backend": "anthropic"}) + assert not [name for name in environment if name.endswith(("_MODEL", "_MODEL_NAME"))] + + def test_a_model_with_no_backend_to_send_it_to_is_reported(self, monkeypatch): + # A stored backend PyBreeze does not offer (hand-edited, or from a newer + # build) has no variable to put the model in. Dropping it quietly would + # leave the dialog showing a model prthinker never sees. + logged: list = [] + monkeypatch.setattr( + prthinker_setting.pybreeze_logger, "error", + lambda message, *args: logged.append(message % args)) + + environment = environment_for( + {**DEFAULT_SETTING, "backend": "a-backend-from-the-future", + "model_name": "the-model"}) + + assert not [name for name in environment if name.endswith(("_MODEL", "_MODEL_NAME"))] + assert logged and "the-model" in logged[0] + + +class TestRuleRetrieval: + # prthinker's local RAG index ships with its repository, not its package, so + # the prthinker PyBreeze installs can only do without it or ask the server. + def test_the_choices_are_off_and_the_server(self): + assert RAG_MODES == ("off", "remote") + + def test_it_starts_off(self): + # Both variables travel every time: the child inherits the IDE's + # environment, so one left out would be decided by whatever is exported + # in the shell PyBreeze was started from. + assert environment_for(DEFAULT_SETTING) | { + "PRTHINKER_RAG_ENABLED": "false", "PRTHINKER_REMOTE_RAG": "false", + } == environment_for(DEFAULT_SETTING) + + def test_remote_asks_the_server(self): + environment = environment_for({**DEFAULT_SETTING, "rag": "remote"}) + assert environment["PRTHINKER_REMOTE_RAG"] == "true" + assert environment["PRTHINKER_RAG_ENABLED"] == "true" + + @pytest.mark.parametrize("stored", ["local", "", "REMOTE "]) + def test_anything_else_counts_as_off(self, stored): + environment = environment_for({**DEFAULT_SETTING, "rag": stored}) + assert environment["PRTHINKER_RAG_ENABLED"] == "false" + assert environment["PRTHINKER_REMOTE_RAG"] == "false" + + class TestTheCommandsAreBuilt: def test_reviewing_a_file_names_that_file(self): assert review_file_arguments("main.py", DEFAULT_SETTING) == \ @@ -92,6 +226,28 @@ def test_a_quoted_argument_stays_in_one_piece(self): setting = {**DEFAULT_SETTING, "extra_arguments": '--marker "a b"'} assert extra_arguments(setting) == ["--marker", "a b"] + def test_a_windows_path_keeps_its_backslashes(self): + assert split_arguments( + r'--output-dir C:\reviews\out --marker "C:\with space\x"', + backslash_escapes=False, + ) == ["--output-dir", r"C:\reviews\out", "--marker", r"C:\with space\x"] + + @pytest.mark.parametrize("backslash_escapes", [False, True]) + def test_a_hash_is_part_of_an_argument_not_a_comment(self, backslash_escapes): + # Everything from the first "#" on was dropped without a word + assert split_arguments( + "--language C# --rules-url https://x/y#sec --focus #security --depth 2", + backslash_escapes=backslash_escapes, + ) == ["--language", "C#", "--rules-url", "https://x/y#sec", "--focus", "#security", "--depth", "2"] + + def test_where_backslash_escapes_it_still_does(self): + assert split_arguments(r"--marker a\ b", backslash_escapes=True) == ["--marker", "a b"] + + def test_the_platform_decides_what_a_backslash_means(self): + setting = {**DEFAULT_SETTING, "extra_arguments": r"--output-dir out\here"} + expected = r"out\here" if os.sep == "\\" else "outhere" + assert extra_arguments(setting) == ["--output-dir", expected] + def test_an_unclosed_quote_costs_only_the_extra_arguments(self): setting = {**DEFAULT_SETTING, "extra_arguments": '--marker "unclosed'} assert extra_arguments(setting) == [] @@ -101,15 +257,37 @@ def test_an_unclosed_quote_costs_only_the_extra_arguments(self): class TestInstallingFromSource: """prthinker is not on PyPI, so pip is pointed at a folder.""" - def test_a_real_folder_becomes_a_target_with_the_extras(self, tmp_path): + @staticmethod + def _source(folder, name="prthinker"): + (folder / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "1.0"\n', encoding="utf-8") + return folder + + def test_its_source_folder_becomes_a_target_with_the_extras(self, tmp_path): + self._source(tmp_path) assert install_target(str(tmp_path)) == f"{tmp_path}[{INSTALL_EXTRAS}]" def test_surrounding_spaces_are_dropped(self, tmp_path): + self._source(tmp_path) assert install_target(f" {tmp_path} ") == f"{tmp_path}[{INSTALL_EXTRAS}]" + def test_any_other_folder_is_no_target(self, tmp_path): + # A wrong pick used to be saved and handed to a pip that could only fail. + assert install_target(str(tmp_path)) == "" + assert install_target(str(self._source(tmp_path, name="prthinker-docs"))) == "" + def test_nothing_chosen_is_no_target(self): assert install_target("") == "" + def test_a_relative_folder_is_given_to_pip_as_a_path(self, tmp_path, monkeypatch): + # "prthinker[runner]" has no separator, and pip took it for the PyPI package + folder = tmp_path / "prthinker" + folder.mkdir() + self._source(folder) + monkeypatch.chdir(tmp_path) + + assert install_target("prthinker") == f"{tmp_path / 'prthinker'}[{INSTALL_EXTRAS}]" + def test_a_path_that_is_not_a_folder_is_no_target(self, tmp_path): a_file = tmp_path / "pyproject.toml" a_file.write_text("", encoding="utf-8") diff --git a/test/test_utils/test_prthinker_setting_dialog.py b/test/test_utils/test_prthinker_setting_dialog.py index a2b0c39d..2bfb7e03 100644 --- a/test/test_utils/test_prthinker_setting_dialog.py +++ b/test/test_utils/test_prthinker_setting_dialog.py @@ -7,11 +7,11 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit +from PySide6.QtWidgets import QApplication, QComboBox, QDialog, QLineEdit, QMessageBox from pybreeze.extend.prthinker_extend import prthinker_setting from pybreeze.extend.prthinker_extend.prthinker_setting import ( - BACKENDS, DEFAULT_SETTING, PLATFORMS, SETTING_FILE_NAME, save_setting + BACKENDS, DEFAULT_SETTING, PLATFORMS, RAG_MODES, SETTING_FILE_NAME, save_setting ) from pybreeze.extend_multi_language.update_language_dict import update_language_dict from pybreeze.pybreeze_ui.dialog import prthinker_setting_dialog @@ -30,7 +30,7 @@ def app(): @pytest.fixture() def data_dir(tmp_path, monkeypatch): """Point the settings file at a temporary directory, never the real home.""" - monkeypatch.setattr(prthinker_setting, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(prthinker_setting, "pybreeze_data_path", lambda: tmp_path) return tmp_path @@ -65,6 +65,11 @@ def test_the_platform_is_chosen_from_the_supported_list(self, dialog): editor = dialog.editors["platform"] assert [editor.itemText(i) for i in range(editor.count())] == list(PLATFORMS) + def test_rule_retrieval_is_chosen_from_the_supported_list(self, dialog): + editor = dialog.editors["rag"] + assert [editor.itemText(i) for i in range(editor.count())] == list(RAG_MODES) + assert editor.currentText() == DEFAULT_SETTING["rag"] + def test_a_stored_choice_comes_back_selected(self, app, data_dir): save_setting({**DEFAULT_SETTING, "backend": "anthropic", "platform": "gitea"}) made = PRThinkerSettingDialog() @@ -115,8 +120,32 @@ def test_a_failed_save_leaves_the_window_open(self, dialog, monkeypatch): # the user needs the form still in front of them to retry or copy from. monkeypatch.setattr( prthinker_setting_dialog, "save_setting", lambda _setting: False) + warned: list = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda _p, _t, text: warned.append(text))) + dialog.save() + assert dialog.result() != QDialog.DialogCode.Accepted + # It said so: the reason used to go to the log only + assert warned and str(prthinker_setting.setting_path()) in warned[0] + + def test_extra_arguments_with_an_open_quote_are_not_saved(self, dialog, data_dir, monkeypatch): + # Saved, they were dropped whole at run time, and the review ran + # without the arguments the user thought it had + warned: list = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda _p, _t, text: warned.append(text))) + dialog.editors["extra_arguments"].setText('--rules "only these') + dialog.save() + + assert warned assert dialog.result() != QDialog.DialogCode.Accepted + assert not (data_dir / SETTING_FILE_NAME).exists() + + def test_readable_extra_arguments_are_saved(self, dialog, data_dir): + dialog.editors["extra_arguments"].setText('--rules "only these"') + + dialog.save() + + assert stored(data_dir)["extra_arguments"] == '--rules "only these"' def test_a_field_left_untouched_keeps_its_stored_value(self, app, data_dir): save_setting({**DEFAULT_SETTING, "model_name": "kept"}) diff --git a/test/test_utils/test_public_http.py b/test/test_utils/test_public_http.py new file mode 100644 index 00000000..d5afb912 --- /dev/null +++ b/test/test_utils/test_public_http.py @@ -0,0 +1,397 @@ +"""The address a request connects to is the address that was checked. + +``validate_url`` resolved the name and the request resolved it again. A name +that answered a public address the first time and a private one the second +(DNS rebinding) passed the check and reached the private address. +""" +from __future__ import annotations + +import socket +import ssl +import threading +import urllib.error +import urllib.request + +import pytest +import requests + +from pybreeze.pybreeze_ui.diagram_editor import diagram_net_utils +from pybreeze.utils.network import url_validation +from pybreeze.utils.network.public_http import ( + PublicHTTPHandler, + PublicHTTPSHandler, + public_session, +) +from pybreeze.utils.network.url_validation import validate_url + +_PUBLIC_IP = "93.184.215.14" +_real_getaddrinfo = socket.getaddrinfo + + +def _answer(ip: str, port) -> list: + # The port asked for: a connection made from this answer goes to it + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, int(port or 0)))] + + +@pytest.fixture() +def dns(monkeypatch): + """Names ending in ``.test`` answer from a table; ``rebind.test`` public first, then loopback.""" + rebind_answers = [_PUBLIC_IP] + table = {"service.test": "127.0.0.1"} + + def getaddrinfo(host, port=None, *args, **kwargs): + if host == "rebind.test": + return _answer(rebind_answers.pop(0) if rebind_answers else "127.0.0.1", port) + if host in table: + return _answer(table[host], port) + return _real_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + + +@pytest.fixture() +def loopback_allowed(monkeypatch): + """Let the checks pass loopback, so a local server can stand in for a public one.""" + monkeypatch.setattr(url_validation, "_is_blocked_ip", lambda _ip: False) + + +def _complete(data: bytes) -> bool: + """True once *data* holds a request's headers, or a whole TLS record (the ClientHello).""" + if data.startswith(b"\x16") and len(data) >= 5: + return len(data) >= 5 + int.from_bytes(data[3:5], "big") + return b"\r\n\r\n" in data + + +class _Listener: + """A loopback server that records the first request it gets, and what it answers.""" + + def __init__(self, reply: bytes = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"): + self.received: list[bytes] = [] + self._reply = reply + self._socket = socket.socket() + self._socket.bind(("127.0.0.1", 0)) + self._socket.listen(1) + self._socket.settimeout(5) + self.port = self._socket.getsockname()[1] + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + + def _serve(self) -> None: + try: + connection, _address = self._socket.accept() + except OSError: + return + with connection: + connection.settimeout(5) + data = b"" + try: + while not _complete(data): + chunk = connection.recv(4096) + if not chunk: + break + data += chunk + except OSError: + pass + self.received.append(data) + if not data.startswith(b"\x16"): + connection.sendall(self._reply) + + def close(self) -> None: + self._socket.close() + self._thread.join(5) + + +def _session() -> requests.Session: + """``public_session()``, ignoring any proxy this machine has set.""" + session = public_session() + session.trust_env = False + return session + + +@pytest.fixture() +def listener(): + server = _Listener() + yield server + server.close() + + +class TestRequestsSession: + def test_a_name_that_rebinds_to_loopback_is_not_reached(self, dns, listener): + url = f"http://rebind.test:{listener.port}/" + validate_url(url) # the first answer is public + + with _session() as session, pytest.raises(requests.ConnectionError) as raised: + session.get(url, timeout=(3, 3), allow_redirects=False) + + assert "non-public address 127.0.0.1" in str(raised.value) + listener.close() + assert listener.received == [] + + def test_it_connects_to_the_checked_address_under_the_name(self, dns, loopback_allowed, listener): + with _session() as session: + response = session.get(f"http://service.test:{listener.port}/path", timeout=(3, 3)) + + assert response.text == "ok" + request = listener.received[0] + assert request.startswith(b"GET /path HTTP/1.1\r\n") + assert f"Host: service.test:{listener.port}\r\n".encode() in request + + def test_tls_names_the_host_not_the_address(self, dns, loopback_allowed, listener): + with _session() as session, pytest.raises(requests.RequestException): + session.get(f"https://service.test:{listener.port}/", timeout=(3, 3)) + + listener.close() + # The ClientHello's server name + assert b"service.test" in listener.received[0] + + def test_a_proxy_on_the_local_network_is_still_used(self, dns, listener): + proxy = f"http://127.0.0.1:{listener.port}" + + with _session() as session: + response = session.get( + "http://rebind.test/", proxies={"http": proxy}, timeout=(3, 3)) + + assert response.text == "ok" + assert listener.received[0].startswith(b"GET http://rebind.test/ HTTP/1.1\r\n") + + +class TestUrllibOpener: + """The opener the diagram editor downloads images with.""" + + def test_a_name_that_rebinds_to_loopback_is_not_reached(self, dns, listener): + url = f"http://rebind.test:{listener.port}/image.png" + validate_url(url) + + with pytest.raises(urllib.error.URLError) as raised: + diagram_net_utils._OPENER.open(url, timeout=3) + + assert "non-public address 127.0.0.1" in str(raised.value) + listener.close() + assert listener.received == [] + + def test_a_rebinding_image_fails_as_a_download_error_the_canvas_catches(self, dns, listener): + with pytest.raises(OSError): + diagram_net_utils.safe_download_image(f"http://rebind.test:{listener.port}/image.png") + + listener.close() + assert listener.received == [] + + def test_it_connects_to_the_checked_address_under_the_name(self, dns, loopback_allowed, listener): + with diagram_net_utils._OPENER.open(f"http://service.test:{listener.port}/i.png", timeout=3) as reply: + assert reply.read() == b"ok" + + assert f"Host: service.test:{listener.port}\r\n".encode() in listener.received[0] + + def test_tls_names_the_host_not_the_address(self, dns, loopback_allowed, listener): + opener = urllib.request.build_opener(PublicHTTPHandler(), PublicHTTPSHandler()) + + with pytest.raises((urllib.error.URLError, ssl.SSLError, ConnectionError)): + opener.open(f"https://service.test:{listener.port}/", timeout=3) + + listener.close() + assert b"service.test" in listener.received[0] + + def test_a_proxy_on_the_local_network_is_still_used(self, dns, listener): + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": f"http://127.0.0.1:{listener.port}"}), + PublicHTTPHandler(), PublicHTTPSHandler()) + + with opener.open("http://rebind.test/i.png", timeout=3) as reply: + assert reply.read() == b"ok" + + assert listener.received[0].startswith(b"GET http://rebind.test/i.png HTTP/1.1\r\n") + + +@pytest.fixture() +def two_addresses(monkeypatch): + """``two.test`` answers an address nothing listens on first, then loopback.""" + def getaddrinfo(host, port=None, *args, **kwargs): + if host == "two.test": + return _answer("127.0.0.2", port) + _answer("127.0.0.1", port) + return _real_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + + +class TestEveryCheckedAddressIsTried: + """A plain connection tries each address; the pinned one dialled only the first.""" + + def test_requests_goes_on_to_the_next_address(self, two_addresses, loopback_allowed, listener): + with _session() as session: + response = session.get(f"http://two.test:{listener.port}/", timeout=(3, 3)) + + assert response.text == "ok" + + def test_urllib_goes_on_to_the_next_address(self, two_addresses, loopback_allowed, listener): + opener = urllib.request.build_opener(PublicHTTPHandler()) + + with opener.open(f"http://two.test:{listener.port}/", timeout=3) as response: + assert response.read() == b"ok" + + def test_one_blocked_address_still_refuses_the_name(self, two_addresses, listener): + with _session() as session, pytest.raises(requests.ConnectionError): + session.get(f"http://two.test:{listener.port}/", timeout=(3, 3)) + listener.close() + assert listener.received == [] + + +class TestInternationalNames: + def test_a_name_is_checked_as_it_will_be_looked_up(self, monkeypatch): + # Python's idna codec made straße.de into strasse.de, another domain: + # the URL was refused as ambiguous + looked_up: list = [] + + def getaddrinfo(host, port=None, *args, **kwargs): + looked_up.append(host) + return _answer(_PUBLIC_IP, port) + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + + assert validate_url("http://straße.de/") == "http://straße.de/" + assert looked_up == ["xn--strae-oqa.de"] + + +class TestTheOverallDeadline: + """A read timeout restarts with every byte; the deadline bounds the whole request.""" + + @staticmethod + def _trickling_headers(stop: threading.Event) -> tuple[socket.socket, threading.Thread]: + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + + def serve() -> None: + try: + connection, _address = server.accept() + except OSError: + return + with connection: + connection.recv(65536) + for byte in b"HTTP/1.1 200 OK\r\nX-Slow: " + b"y" * 1000: + if stop.wait(0.2): + return + try: + connection.sendall(bytes([byte])) + except OSError: + return + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + return server, thread + + def test_headers_sent_a_byte_at_a_time_are_cut_off(self, dns, loopback_allowed): + import time + + from pybreeze.utils.network.public_http import overall_deadline + + stop = threading.Event() + server, thread = self._trickling_headers(stop) + started = time.monotonic() + try: + with pytest.raises(requests.exceptions.ReadTimeout), overall_deadline(1), _session() as session: + session.get(f"http://service.test:{server.getsockname()[1]}/", timeout=(3, 3), stream=True) + assert time.monotonic() - started < 4 + finally: + stop.set() + server.close() + thread.join(5) + + def test_a_timely_answer_is_not_touched(self, dns, loopback_allowed, listener): + from pybreeze.utils.network.public_http import overall_deadline + + with overall_deadline(5), _session() as session: + response = session.get(f"http://service.test:{listener.port}/", timeout=(3, 3)) + + assert response.text == "ok" + + def test_the_urllib_opener_is_cut_off_too(self, dns, loopback_allowed): + # The diagram editor's image downloads: 15 s per wait, restarted by every byte + import time + + from pybreeze.utils.network.public_http import overall_deadline + + stop = threading.Event() + server, thread = self._trickling_headers(stop) + opener = urllib.request.build_opener(PublicHTTPHandler()) + started = time.monotonic() + try: + with pytest.raises(requests.exceptions.ReadTimeout), overall_deadline(1): + opener.open(f"http://service.test:{server.getsockname()[1]}/", timeout=3).read() + assert time.monotonic() - started < 4 + finally: + stop.set() + server.close() + thread.join(5) + + def test_a_body_cut_short_without_a_length_is_not_taken_as_whole(self, dns, loopback_allowed): + # Shut down mid-body with no Content-Length, the read ends as if the body had + import time + + from pybreeze.utils.network.public_http import overall_deadline + + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + stop = threading.Event() + + def serve() -> None: + connection, _address = server.accept() + with connection: + connection.recv(65536) + connection.sendall(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") + while not stop.wait(0.2): + try: + connection.sendall(b"x") + except OSError: + return + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + opener = urllib.request.build_opener(PublicHTTPHandler()) + started = time.monotonic() + try: + with pytest.raises(requests.exceptions.ReadTimeout), overall_deadline(1): + opener.open(f"http://service.test:{server.getsockname()[1]}/", timeout=3).read() + assert time.monotonic() - started < 4 + finally: + stop.set() + server.close() + thread.join(5) + + +class TestARedirectIsNotLookedInto: + """Nothing follows a redirect through these; its body and Location stay unread.""" + + @pytest.mark.parametrize("location", ["http://[bad/x", "http://elsewhere.test/"]) + def test_the_session_hands_the_redirect_back_unread(self, dns, loopback_allowed, location): + # Session.send prepared Response.next even with allow_redirects=False: + # it read the whole body and parsed Location, raising ValueError for [bad + server = _Listener( + f"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 1000000000\r\n" + f"Connection: close\r\n\r\npartial".encode()) + try: + with _session() as session: + response = session.get(f"http://service.test:{server.port}/", timeout=(3, 3), + allow_redirects=False, stream=True) + + assert response.status_code == 302 + assert response.next is None + assert not response._content_consumed + response.close() + finally: + server.close() + + def test_the_image_opener_does_not_read_the_redirect_body(self, dns, loopback_allowed): + # http_error_302 read the redirect's whole body before following it + image = _Listener(b"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 3\r\n" + b"Connection: close\r\n\r\nPNG") + redirect = _Listener( + f"HTTP/1.1 302 Found\r\nLocation: http://service.test:{image.port}/i.png\r\n" + f"Content-Length: 1000000000\r\nConnection: close\r\n\r\npartial".encode()) + try: + data = diagram_net_utils.safe_download_image(f"http://service.test:{redirect.port}/") + + assert data == b"PNG" + finally: + redirect.close() + image.close() diff --git a/test/test_utils/test_query_convert.py b/test/test_utils/test_query_convert.py index 49bbcee9..76054891 100644 --- a/test/test_utils/test_query_convert.py +++ b/test/test_utils/test_query_convert.py @@ -76,3 +76,65 @@ def test_query_json_query(self): def test_repeated_key_round_trip(self): assert json_to_query(query_to_json("a=1&a=2")) == "a=1&a=2" + + +class TestValuesWithoutAQueryForm: + def test_null_is_an_empty_value(self): + from pybreeze.utils.query_tools.query_convert import json_to_query + + assert json_to_query('{"a": null, "b": 1}') == "a=&b=1" + + @pytest.mark.parametrize("text", ['{"a": {"c": 1}}', '{"a": [[1]]}', '{"a": [{"c": 1}]}']) + def test_an_object_or_a_nested_list_is_refused(self, text): + from pybreeze.utils.query_tools.query_convert import json_to_query + + with pytest.raises(QueryConvertException): + json_to_query(text) + + +def test_json_nested_too_deep_is_reported_not_raised_out_of_the_tab(): + from pybreeze.utils.exception.exceptions import QueryConvertException + from pybreeze.utils.query_tools.query_convert import json_to_query + + # json.loads raises RecursionError, which the tab did not catch + with pytest.raises(QueryConvertException): + json_to_query("[" * 100000) + + +class TestValuesAsWritten: + @pytest.mark.parametrize(("value", "sent"), [ + ("1E3", "1E3"), ("1.10", "1.10"), ("12345678901234567890123.0", "12345678901234567890123.0"), + ("1e400", "1e400"), ("-0", "-0"), + ]) + def test_a_number_goes_out_as_written(self, value, sent): + # Through float: 1000.0, 1.1, 1.2345678901234568e+22, inf + assert json_to_query(f'{{"v": {value}}}') == f"v={sent}".replace("+", "%2B") + + @pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) + def test_python_constants_are_not_json(self, constant): + with pytest.raises(QueryConvertException): + json_to_query(f'{{"v": {constant}}}') + + def test_a_lone_surrogate_is_refused_not_raised(self): + # The UnicodeEncodeError escaped the tab's slot + with pytest.raises(QueryConvertException): + json_to_query('{"q": "\ud83d"}') + + +class TestNothingIsLostWithoutAWord: + def test_a_percent_escape_that_is_not_utf8_is_refused(self): + # It became U+FFFD and the byte was gone + with pytest.raises(QueryConvertException, match="not UTF-8"): + query_to_json("a=%B0") + + def test_a_repeated_json_key_is_refused(self): + # {"a": 1, "a": 2} went out as a=2; a list is how a key repeats + with pytest.raises(QueryConvertException, match="twice"): + json_to_query('{"a": 1, "a": 2}') + + def test_a_repeated_url_part_is_refused(self): + from pybreeze.utils.exception.exceptions import UrlConvertException + from pybreeze.utils.url_tools.url_convert import json_to_url + + with pytest.raises(UrlConvertException, match="twice"): + json_to_url('{"scheme": "http", "host": "a", "host": "b"}') diff --git a/test/test_utils/test_query_json_gui.py b/test/test_utils/test_query_json_gui.py index f86852b9..5406f48d 100644 --- a/test/test_utils/test_query_json_gui.py +++ b/test/test_utils/test_query_json_gui.py @@ -60,3 +60,14 @@ def test_copy_output(self, app, widget): widget.convert_to_json() widget.actions.copy() assert "a" in QApplication.clipboard().text() + + +def test_a_query_with_no_json_form_says_so_and_cannot_be_saved(widget): + widget.input_edit.setPlainText("a=1") + widget.convert_to_json() + widget.input_edit.setPlainText("a=%B0") + + widget.convert_to_json() + + assert "UTF-8" in widget.output_edit.toPlainText() + assert widget._valid_output is False diff --git a/test/test_utils/test_queue_pump.py b/test/test_utils/test_queue_pump.py index 36b933db..5cb93472 100644 --- a/test/test_utils/test_queue_pump.py +++ b/test/test_utils/test_queue_pump.py @@ -1,10 +1,12 @@ from __future__ import annotations +import io from queue import Queue from pybreeze.extend.process_executor.queue_pump import ( MAX_MESSAGES_PER_PUMP, pump_message_queue, + read_stream_into_queue, ) @@ -22,6 +24,16 @@ def _collect_pump(messages, *, max_messages=MAX_MESSAGES_PER_PUMP): return q, received +def _read_all(data: bytes, *, keep_reading=lambda: True) -> list[str]: + q: Queue = Queue() + read_stream_into_queue( + io.BytesIO(data), q, buffer_size=1024, encoding="utf-8", keep_reading=keep_reading) + items = [] + while not q.empty(): + items.append(q.get()) + return items + + class TestPumpBatching: def test_drains_many_messages_in_one_call(self): q, received = _collect_pump([f"line {i}" for i in range(50)]) @@ -33,12 +45,22 @@ def test_respects_max_messages_bound(self): assert len(received) == 4 assert q.qsize() == 6 # remaining drained on later ticks + def test_none_drains_everything(self): + q, received = _collect_pump( + [f"line {i}" for i in range(MAX_MESSAGES_PER_PUMP * 3)], max_messages=None) + assert len(received) == MAX_MESSAGES_PER_PUMP * 3 + assert q.empty() + def test_empty_queue_is_noop(self): _, received = _collect_pump([]) assert received == [] - def test_blank_messages_are_skipped(self): - _, received = _collect_pump([" ", "\n", "real"]) + def test_messages_pass_through_unchanged(self): + _, received = _collect_pump([" indented\n", "\n", " \n", "last"]) + assert [text for text, _ in received] == [" indented\n", "\n", " \n", "last"] + + def test_only_empty_messages_are_skipped(self): + _, received = _collect_pump(["", "real"]) assert received == [("real", False)] def test_is_error_flag_forwarded(self): @@ -49,3 +71,192 @@ def test_is_error_flag_forwarded(self): q, lambda text, is_error: received.append((text, is_error)), is_error=True ) assert received == [("boom", True)] + + +class TestReadStreamIntoQueue: + def test_lines_keep_indentation_blank_lines_and_endings(self): + data = b"def f():\n return 1\n\n\tdone\r\n" + assert "".join(_read_all(data)) == "def f():\n return 1\n\n\tdone\r\n" + + def test_bytes_are_decoded_with_replacement(self): + assert "".join(_read_all("café\n".encode() + b"\xff\n")) == "café\n�\n" + + def test_what_has_arrived_is_passed_on_without_waiting_for_a_newline(self): + # readline waited for one: a progress bar rewinding with \r showed nothing + # until the run's next newline + class Trickle: + def __init__(self) -> None: + self.pieces = [b"0%\r", b"50%\r", b"100%\r", b"done\n"] + + def read1(self, _size: int) -> bytes: + return self.pieces.pop(0) if self.pieces else b"" + + target: Queue = Queue() + read_stream_into_queue(Trickle(), target, buffer_size=1024, encoding="utf-8", keep_reading=lambda: True) + + got = [] + while not target.empty(): + got.append(target.get()) + # Each piece as it came (a trailing \r held for the next one) + assert got == ["0%", "\r50%", "\r100%", "\rdone\n"] + + def test_stops_when_told_to(self): + assert _read_all(b"one\ntwo\n", keep_reading=lambda: False) == [] + + def test_a_closed_pipe_ends_the_reader(self): + class Closed: + def readline(self, _size): + raise ValueError("I/O operation on closed file") + + q: Queue = Queue() + read_stream_into_queue( + Closed(), q, buffer_size=16, encoding="utf-8", keep_reading=lambda: True) + assert q.empty() + + def test_a_line_longer_than_the_buffer_arrives_in_pieces(self): + # The pieces carry no line break of their own, so displayed back to back + # they form the original line again. + pieces = [] + q: Queue = Queue() + read_stream_into_queue( + io.BytesIO(b"abcdefghij\n"), q, buffer_size=4, encoding="utf-8", + keep_reading=lambda: True) + while not q.empty(): + pieces.append(q.get()) + assert pieces == ["abcd", "efgh", "ij\n"] + + def _pieces(self, data: bytes, buffer_size: int, encoding: str = "utf-8") -> list[str]: + q: Queue = Queue() + read_stream_into_queue( + io.BytesIO(data), q, buffer_size=buffer_size, encoding=encoding, + keep_reading=lambda: True) + items = [] + while not q.empty(): + items.append(q.get()) + return items + + def test_a_character_cut_by_the_buffer_is_joined(self): + # Each of these characters is three bytes; a four-byte buffer cuts them. + text = "中文輸出\n" + pieces = self._pieces(text.encode("utf-8"), buffer_size=4) + assert "".join(pieces) == text + assert "�" not in "".join(pieces) + + def test_a_line_ending_cut_by_the_buffer_stays_one_line_ending(self): + pieces = self._pieces(b"abc\r\ndef\r\n", buffer_size=4) + # Normalised for display piece by piece, a lone "\r" then "\n" would be + # two line breaks. + assert not any(piece.endswith("\r") for piece in pieces) + assert "".join(pieces) == "abc\r\ndef\r\n" + + def test_a_carriage_return_at_the_very_end_is_not_lost(self): + assert "".join(self._pieces(b"50%\r", buffer_size=1024)) == "50%\r" + + def test_an_unknown_encoding_falls_back_to_utf8(self): + assert self._pieces("ok 中\n".encode("utf-8"), 1024, encoding="no-such-codec") == ["ok 中\n"] + + +class TestABoundedQueue: + """A reader waits on a full queue instead of holding every line of a runaway run in memory.""" + + def test_the_reader_waits_while_the_queue_is_full(self): + import threading + + from pybreeze.extend.process_executor import queue_pump + + class LineByLine: + """A pipe that has one line ready each time it is read.""" + + def __init__(self) -> None: + self.lines = [f"{i}\n".encode() for i in range(10)] + + def read1(self, _size: int) -> bytes: + return self.lines.pop(0) if self.lines else b"" + + target: Queue = Queue(maxsize=3) + reader = threading.Thread(target=queue_pump.read_stream_into_queue, args=(LineByLine(), target), + kwargs={"buffer_size": 1024, "encoding": "utf-8", "keep_reading": lambda: True}) + reader.start() + reader.join(0.5) + + assert reader.is_alive() and target.qsize() == 3 + got = [target.get(timeout=2) for _ in range(10)] + reader.join(2) + assert got == [f"{i}\n" for i in range(10)] + assert not reader.is_alive() + + def test_a_waiting_reader_gives_up_once_told_to_stop(self): + import threading + + from pybreeze.extend.process_executor import queue_pump + + target: Queue = Queue(maxsize=1) + running = {"on": True} + reader = threading.Thread(target=queue_pump.read_stream_into_queue, args=(io.BytesIO(b"a\nb\nc\n"), target), + kwargs={"buffer_size": 1024, "encoding": "utf-8", + "keep_reading": lambda: running["on"]}) + reader.start() + reader.join(0.3) + running["on"] = False + reader.join(2) + + assert not reader.is_alive() + + def test_the_executors_queues_are_bounded(self): + from pybreeze.extend.process_executor.queue_pump import MAX_QUEUED_MESSAGES, output_queue + + assert output_queue().maxsize == MAX_QUEUED_MESSAGES > 0 + + +class TestTheGraceWhileOutputStillComes: + def test_output_still_arriving_keeps_the_run_open(self, monkeypatch): + import threading + + from pybreeze.extend.process_executor import queue_pump + + now = {"t": 100.0} + monkeypatch.setattr(queue_pump.time, "monotonic", lambda: now["t"]) + alive = threading.Event() + reader = threading.Thread(target=alive.wait, args=(5,)) + reader.start() + grace = queue_pump.ReaderGrace() + try: + assert grace.still_reading(reader) + now["t"] += 1.5 + assert grace.still_reading(reader, progressed=True) + now["t"] += 1.5 # three seconds after the exit, but output came 1.5 s ago + assert grace.still_reading(reader) + now["t"] += 1.0 + assert not grace.still_reading(reader) + finally: + alive.set() + reader.join() + + def test_never_past_the_drain_limit(self, monkeypatch): + import threading + + from pybreeze.extend.process_executor import queue_pump + + now = {"t": 0.0} + monkeypatch.setattr(queue_pump.time, "monotonic", lambda: now["t"]) + alive = threading.Event() + reader = threading.Thread(target=alive.wait, args=(5,)) + reader.start() + grace = queue_pump.ReaderGrace() + try: + grace.still_reading(reader) + now["t"] = queue_pump.MAX_DRAIN_SECONDS + 0.1 + assert not grace.still_reading(reader, progressed=True) + finally: + alive.set() + reader.join() + + +def test_the_pump_says_how_many_it_took(): + from pybreeze.extend.process_executor.queue_pump import pump_message_queue + + target: Queue = Queue() + for message in ("a", "", "b"): + target.put(message) + + assert pump_message_queue(target, lambda _text, _is_error: None, is_error=False) == 3 diff --git a/test/test_utils/test_read_capped.py b/test/test_utils/test_read_capped.py new file mode 100644 index 00000000..2522cd14 --- /dev/null +++ b/test/test_utils/test_read_capped.py @@ -0,0 +1,74 @@ +"""A file the user opens is not read on the UI thread when it is too large.""" +from __future__ import annotations + +import json +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.utils.file_process import read_capped +from pybreeze.utils.file_process.read_capped import FileTooLargeError, read_text_capped + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def test_a_file_within_the_limit_is_read(tmp_path): + path = tmp_path / "small.json" + path.write_text("{}", encoding="utf-8") + + assert read_text_capped(path, max_bytes=10) == "{}" + + +def test_a_file_over_the_limit_is_refused_as_a_read_error(tmp_path): + path = tmp_path / "big.json" + path.write_bytes(b"x" * 11) + + with pytest.raises(FileTooLargeError) as raised: + read_text_capped(path, max_bytes=10) + + assert isinstance(raised.value, OSError) and "not opened" in raised.value.strerror + + +def test_the_har_tab_says_why_it_did_not_open_a_huge_export(app, tmp_path, monkeypatch): + # It read a multi-GB export whole, on the UI thread + from pybreeze.pybreeze_ui.tools_gui import har_import_gui + + monkeypatch.setattr(read_capped, "MAX_OPEN_BYTES", 10) + path = tmp_path / "session.har" + path.write_text(json.dumps({"log": {"entries": []}}), encoding="utf-8") + monkeypatch.setattr(har_import_gui.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(path), ""))) + tab = har_import_gui.HarImportGUI() + + assert tab.open_file() is None + assert "not opened" in tab.summary_label.text() + tab.close() + + +def test_the_diagram_editor_says_why_it_did_not_open_a_huge_file(app, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.diagram_editor import diagram_editor_widget + + monkeypatch.setattr(read_capped, "MAX_OPEN_BYTES", 10) + path = tmp_path / "big.diagram.json" + path.write_text(json.dumps({"nodes": [], "connections": [], "images": []}), encoding="utf-8") + said: list = [] + monkeypatch.setattr(diagram_editor_widget.QFileDialog, "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(path), ""))) + monkeypatch.setattr(diagram_editor_widget.QMessageBox, "warning", + staticmethod(lambda *args, **kwargs: said.append(args[2]))) + editor = diagram_editor_widget.DiagramEditorWidget() + + editor._open_diagram() + + assert said and "not opened" in said[0] + assert editor._current_path is None + editor.deleteLater() diff --git a/test/test_utils/test_regex_gui.py b/test/test_utils/test_regex_gui.py index f638cfa7..e49aaf7d 100644 --- a/test/test_utils/test_regex_gui.py +++ b/test/test_utils/test_regex_gui.py @@ -18,6 +18,14 @@ def app(): return instance +def run(widget) -> None: + """Start the pattern and wait for its answer, as the tab would on the event loop.""" + widget.test() + thread = widget._match_thread + assert thread.wait(30_000), "the pattern did not come back" + QApplication.processEvents() + + @pytest.fixture() def widget(app): from pybreeze.pybreeze_ui.tools_gui.regex_gui import RegexGUI @@ -31,29 +39,41 @@ class TestRegexGUI: def test_finds_matches(self, widget): widget.pattern_edit.setText(r"\d+") widget.text_edit.setPlainText("a1 b22") - widget.test() + run(widget) output = widget.output_edit.toPlainText() assert "'1'" in output assert "'22'" in output + def test_enter_in_the_pattern_runs_it(self, widget): + # Only the button ran it + widget.pattern_edit.setText(r"\d+") + widget.text_edit.setPlainText("a1 b22") + + widget.pattern_edit.returnPressed.emit() + + assert widget._match_thread is not None, "Enter started nothing" + assert widget._match_thread.wait(30_000), "the pattern did not come back" + QApplication.processEvents() + assert "'22'" in widget.output_edit.toPlainText() + def test_no_match_message(self, widget): widget.pattern_edit.setText(r"z") widget.text_edit.setPlainText("abc") - widget.test() + run(widget) assert widget.output_edit.toPlainText() != "" assert "'z'" not in widget.output_edit.toPlainText() def test_invalid_pattern_shows_error(self, widget): widget.pattern_edit.setText("(") widget.text_edit.setPlainText("abc") - widget.test() + run(widget) assert widget.output_edit.toPlainText() != "" def test_ignorecase_flag_used(self, widget): widget.pattern_edit.setText("abc") widget.text_edit.setPlainText("ABC") widget.flag_checkboxes["IGNORECASE"].setChecked(True) - widget.test() + run(widget) assert "'ABC'" in widget.output_edit.toPlainText() widget.flag_checkboxes["IGNORECASE"].setChecked(False) @@ -65,10 +85,76 @@ def test_selected_flags(self, widget): def test_copy_output(self, app, widget): widget.pattern_edit.setText(r"\d+") widget.text_edit.setPlainText("a1") - widget.test() + run(widget) widget.actions.copy() assert "'1'" in QApplication.clipboard().text() def test_build_matches_text_no_matches(self, app): from pybreeze.pybreeze_ui.tools_gui.regex_gui import build_matches_text assert build_matches_text([], "none") == "none" + + +class TestAPatternThatNeverFinishes: + def test_the_tab_does_not_wait_for_it(self, widget, monkeypatch): + import threading + + from pybreeze.pybreeze_ui.tools_gui import regex_gui + + answering = threading.Event() + + def slow(*_args): + answering.wait(10) + return [] + + monkeypatch.setattr(regex_gui, "find_matches_bounded", slow) + widget.pattern_edit.setText("a") + widget.text_edit.setPlainText("a") + + widget.test() + + assert widget._match_thread.isRunning(), "test() waited for the pattern" + assert not widget.test_button.isEnabled() + answering.set() + assert widget._match_thread.wait(10_000) + QApplication.processEvents() + assert widget.test_button.isEnabled() + + def test_catastrophic_backtracking_is_stopped_and_reported(self, widget, monkeypatch): + from pybreeze.utils.regex_tools import regex_tester + + # A 2 s deadline instead of the tab's 5 s, to keep the test short. The + # default is bound at definition, so it is the defaults that are patched. + monkeypatch.setattr( + regex_tester.find_matches_bounded, "__defaults__", (None, 2.0)) + widget.pattern_edit.setText("(a+)+$") + widget.text_edit.setPlainText("a" * 40 + "b") + + run(widget) + + assert "still running" in widget.output_edit.toPlainText() + + +class TestWhatTheTabOffersToSave: + def test_nothing_is_saved_while_a_pattern_runs(self, widget): + # Save during a run wrote "Running the pattern..." + widget.pattern_edit.setText(r"\d") + widget.text_edit.setPlainText("a1") + run(widget) + assert widget._valid_output + + widget.pattern_edit.setText("(a+)+$") + widget.text_edit.setPlainText("a" * 60 + "b") + widget.test() + + assert widget._valid_output is False + widget.close() # stops the worker + widget._match_thread.wait(30_000) + + def test_a_list_cut_at_the_cap_says_so(self, app): + from pybreeze.pybreeze_ui.tools_gui.regex_gui import build_matches_text + from pybreeze.utils.regex_tools.regex_tester import MAX_MATCHES, MatchResult + + matches = [MatchResult("a", i, i + 1) for i in range(MAX_MATCHES)] + + assert "may be more" in build_matches_text(matches, "none") + assert "may be more" not in build_matches_text(matches[:3], "none") diff --git a/test/test_utils/test_regex_tester.py b/test/test_utils/test_regex_tester.py index 875a2654..e5af7424 100644 --- a/test/test_utils/test_regex_tester.py +++ b/test/test_utils/test_regex_tester.py @@ -90,5 +90,119 @@ def test_group_that_did_not_match_is_none(self): def test_match_cap(self): from pybreeze.utils.regex_tools import regex_tester # More potential matches than the cap; result is capped, not unbounded. - text = "a" * (regex_tester._MAX_MATCHES + 50) - assert len(find_matches("a", text)) == regex_tester._MAX_MATCHES + text = "a" * (regex_tester.MAX_MATCHES + 50) + assert len(find_matches("a", text)) == regex_tester.MAX_MATCHES + + +class TestABoundedRun: + """find_matches_bounded runs the pattern in a process it can stop.""" + + def test_it_finds_what_find_matches_finds(self): + from pybreeze.utils.regex_tools.regex_tester import find_matches, find_matches_bounded + + assert find_matches_bounded(r"\d+", "a1 b22") == find_matches(r"\d+", "a1 b22") + + def test_a_malformed_pattern_is_reported_before_any_process(self, monkeypatch): + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools import regex_tester + + monkeypatch.setattr( + regex_tester.subprocess, "Popen", + lambda *_a, **_k: pytest.fail("a process was started for a pattern that cannot compile")) + + with pytest.raises(RegexTesterException): + regex_tester.find_matches_bounded("(", "abc") + + def test_catastrophic_backtracking_is_stopped(self): + import time + + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools.regex_tester import find_matches_bounded + + started = time.monotonic() + with pytest.raises(RegexTesterException, match="still running"): + find_matches_bounded("(a+)+$", "a" * 40 + "b", timeout_seconds=2.0) + + assert time.monotonic() - started < 10 + + +class TestPatternsTheCompilerCannotTake: + """They escaped the worker, and the tab stayed on "Running the pattern...".""" + + def test_a_repeat_count_too_large_is_reported(self): + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools.regex_tester import compile_pattern + + with pytest.raises(RegexTesterException): + compile_pattern("a{4294967296}") + + def test_groups_nested_too_deep_are_reported(self): + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools.regex_tester import compile_pattern + + with pytest.raises(RegexTesterException): + compile_pattern("(" * 5000 + "a" + ")" * 5000) + + +class TestTheWorkerProcess: + def test_an_unguarded_launch_script_runs_once(self, tmp_path): + # A spawn child re-imported it: the README's start_editor() script + # opened a second IDE for every pattern, which then "timed out" + import subprocess + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + script = tmp_path / "launch.py" + script.write_text( + "import sys\n" + f"sys.path.insert(0, {str(root)!r})\n" + "print('STARTED', flush=True)\n" + "from pybreeze.utils.regex_tools.regex_tester import find_matches_bounded\n" + "print('FOUND', len(find_matches_bounded(r'\\d', 'a1 b2')), flush=True)\n", + encoding="utf-8") + + result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, + timeout=60, check=False) + + assert result.stdout.count("STARTED") == 1, result.stderr + assert "FOUND 2" in result.stdout + + def test_a_running_pattern_can_be_stopped(self): + # Closing the IDE ends with os._exit, which left the worker running + import threading + import time + + from pybreeze.utils.exception.exceptions import RegexTesterException + from pybreeze.utils.regex_tools import regex_tester + + failures: list = [] + + def run() -> None: + try: + regex_tester.find_matches_bounded("(a+)+$", "a" * 60 + "b", timeout_seconds=60) + except RegexTesterException as error: + failures.append(error) + + worker = threading.Thread(target=run) + worker.start() + deadline = time.monotonic() + 20 + while not regex_tester._RUNNING and time.monotonic() < deadline: + time.sleep(0.05) + started = time.monotonic() + regex_tester.stop_running_workers() + worker.join(20) + + assert failures and time.monotonic() - started < 10 + assert not regex_tester._RUNNING + + def test_the_packaged_builds_spawned_worker_finds_matches(self): + # A packaged build has no interpreter to run a script with; it keeps the + # spawn child. (Setting sys.frozen here would make multiprocessing hand + # this interpreter the frozen app's arguments.) + from pybreeze.utils.regex_tools import regex_tester + + found = regex_tester._find_in_spawned_process(r"\d+", "a1 b22", [], 30) + + assert found == regex_tester.find_matches(r"\d+", "a1 b22") + assert not regex_tester._RUNNING diff --git a/test/test_utils/test_replace_file.py b/test/test_utils/test_replace_file.py new file mode 100644 index 00000000..521e7740 --- /dev/null +++ b/test/test_utils/test_replace_file.py @@ -0,0 +1,116 @@ +"""A file written through replace_text is either the old one or the new one, never half.""" +from __future__ import annotations + +import os +import sys + +import pytest + +from pybreeze.utils.file_process import replace_file +from pybreeze.utils.file_process.replace_file import replace_text + + +def test_the_text_is_written(tmp_path): + target = tmp_path / "settings.json" + + replace_text(target, "new text\n") + + assert target.read_text(encoding="utf-8") == "new text\n" + + +def test_a_failure_part_way_leaves_the_old_file_and_no_partial_one(tmp_path, monkeypatch): + target = tmp_path / "settings.json" + target.write_text("old text", encoding="utf-8") + + def disk_full(*_args, **_kwargs): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(replace_file.os, "replace", disk_full) + + with pytest.raises(OSError): + replace_text(target, "new text") + + assert target.read_text(encoding="utf-8") == "old text" + assert [path.name for path in tmp_path.iterdir()] == ["settings.json"] + + +def test_a_partial_file_left_by_an_earlier_failure_is_replaced(tmp_path): + target = tmp_path / "settings.json" + (tmp_path / "settings.json.saving").write_text("half of an old save", encoding="utf-8") + + replace_text(target, "new text") + + assert target.read_text(encoding="utf-8") == "new text" + assert not (tmp_path / "settings.json.saving").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits") +def test_a_private_file_is_readable_by_its_owner_only(tmp_path): + target = tmp_path / "keys.json" + + replace_text(target, "{}", private=True) + + assert os.stat(target).st_mode & 0o777 == 0o600 + + +class TestReplaceWritten: + def test_the_writer_gets_a_file_with_the_same_extension(self, tmp_path): + from pybreeze.utils.file_process.replace_file import replace_written + + given: list = [] + + def write(target): + given.append(target) + target.write_bytes(b"new") + + replace_written(tmp_path / "out.png", write) + + assert given[0].suffix == ".png" and given[0].parent == tmp_path + assert (tmp_path / "out.png").read_bytes() == b"new" + assert list(tmp_path.iterdir()) == [tmp_path / "out.png"] + + def test_a_writer_that_fails_leaves_the_old_file_and_nothing_else(self, tmp_path): + import pytest + + from pybreeze.utils.file_process.replace_file import replace_written + + old = tmp_path / "out.svg" + old.write_bytes(b"old") + + def write(target): + target.write_bytes(b"ha") + raise OSError("disk full") + + with pytest.raises(OSError): + replace_written(old, write) + + assert old.read_bytes() == b"old" + assert list(tmp_path.iterdir()) == [old] + + def test_a_writer_that_fails_otherwise_leaves_nothing_behind_either(self, tmp_path): + from pybreeze.utils.file_process.replace_file import replace_written + + old = tmp_path / "out.svg" + old.write_bytes(b"old") + + def write(target): + target.write_bytes(b"ha") + raise ValueError("not an image") + + with pytest.raises(ValueError): + replace_written(old, write) + + assert old.read_bytes() == b"old" + assert list(tmp_path.iterdir()) == [old] + + +def test_text_utf8_cannot_encode_leaves_the_file_and_nothing_else(tmp_path): + # UnicodeEncodeError is no OSError: the partial .saving stayed + target = tmp_path / "f.txt" + target.write_text("old", encoding="utf-8") + + with pytest.raises(UnicodeEncodeError): + replace_text(target, "x" + chr(0xD800)) + + assert target.read_text(encoding="utf-8") == "old" + assert list(tmp_path.iterdir()) == [target] diff --git a/test/test_utils/test_request_error_text.py b/test/test_utils/test_request_error_text.py new file mode 100644 index 00000000..c6654c9a --- /dev/null +++ b/test/test_utils/test_request_error_text.py @@ -0,0 +1,114 @@ +"""A failed request is described to the user without its URL. + +A ``requests`` error quotes the URL, path and query included, and an API URL +may carry a token. The logs already left it out; the panels showed it. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +import requests + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.utils.network.http_client import ResponseTooLargeError, describe_request_error +from pybreeze.utils.network.url_validation import UnsafeURLError + +_SECRET = "sk-live-not-a-real-key" +_URL = f"https://api.example/v1/review?api_key={_SECRET}" +_QUOTING_THE_URL = f"HTTPSConnectionPool(host='api.example', port=443): Max retries exceeded with url: {_URL}" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + from PySide6.QtWidgets import QApplication + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class _Refusing: + """A session whose every request raises *error*.""" + + def __init__(self, error: Exception) -> None: + self._error = error + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + """Nothing to close.""" + + def close(self) -> None: + """Nothing to close.""" + + def post(self, *_args, **_kwargs): + raise self._error + + def request(self, *_args, **_kwargs): + raise self._error + + +class TestDescribeRequestError: + @pytest.mark.parametrize(("error", "words"), [ + (requests.ConnectTimeout(_QUOTING_THE_URL), "timed out"), + (requests.ReadTimeout(_QUOTING_THE_URL), "timed out"), + (requests.exceptions.SSLError(_QUOTING_THE_URL), "secure connection"), + (requests.ConnectionError(_QUOTING_THE_URL), "could not connect"), + (requests.TooManyRedirects(_QUOTING_THE_URL), "too many redirects"), + (requests.exceptions.InvalidURL(_QUOTING_THE_URL), "not valid"), + (requests.RequestException(_QUOTING_THE_URL), "RequestException"), + ]) + def test_it_says_what_happened_and_not_where(self, error, words): + text = describe_request_error(error) + + assert words in text + assert _SECRET not in text and "api.example" not in text + + def test_the_size_cap_and_the_ssrf_check_keep_their_own_words(self): + assert describe_request_error(ResponseTooLargeError("too big")) == "too big" + assert describe_request_error(UnsafeURLError("blocked")) == "blocked" + + +def test_the_skills_panel_does_not_show_the_url(monkeypatch): + from pybreeze.pybreeze_ui.extend_ai_gui.skills import skills_send_gui + + monkeypatch.setattr(skills_send_gui, "validate_url", lambda url: url) + monkeypatch.setattr( + skills_send_gui, "public_session", lambda: _Refusing(requests.ConnectionError(_QUOTING_THE_URL))) + errors: list = [] + thread = skills_send_gui.RequestThread(_URL, "print(1)") + thread.error.connect(errors.append) + + thread.run() + + assert errors and all(_SECRET not in error for error in errors) + + +def test_the_review_client_does_not_show_the_url(monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.url import ai_code_review_gui + + monkeypatch.setattr(ai_code_review_gui, "validate_url", lambda url: url) + monkeypatch.setattr( + ai_code_review_gui, "public_session", lambda: _Refusing(requests.ConnectionError(_QUOTING_THE_URL))) + failed: list = [] + thread = ai_code_review_gui.ReviewRequestThread("POST", _URL, "print(1)") + thread.failed.connect(failed.append) + + thread.run() + + assert failed and all(_SECRET not in message for message in failed) + + +def test_the_cot_review_does_not_show_the_url(): + from pybreeze.pybreeze_ui.extend_ai_gui.code_review.code_review_thread import SenderThread + + thread = SenderThread(files=["linter.md"], code="print(1)", url=_URL) + + reply, answered = thread._ask(_Refusing(requests.ConnectionError(_QUOTING_THE_URL)), "linter.md", "prompt") + + assert not answered + assert _SECRET not in reply and "could not connect" in reply diff --git a/test/test_utils/test_response_analyzer.py b/test/test_utils/test_response_analyzer.py index 26c628b3..88ab9541 100644 --- a/test/test_utils/test_response_analyzer.py +++ b/test/test_utils/test_response_analyzer.py @@ -4,6 +4,8 @@ import base64 import json +import pytest + from pybreeze.utils.response_inspector.response_analyzer import analyze_response @@ -29,9 +31,21 @@ def test_no_status_line(self): analysis = analyze_response('{"a": 1}') assert analysis.status is None - def test_unknown_status_code(self): + def test_an_unregistered_code_is_shown_with_its_class_and_own_phrase(self): + # It was dropped: no status section, and Open status greyed out analysis = analyze_response("HTTP/1.1 299 Weird\n\n") - assert analysis.status is None # 299 is not a known code + + assert (analysis.status.code, analysis.status.phrase, analysis.status.category) == ( + 299, "Weird", "Success") + + def test_an_unregistered_pseudo_header_status_has_no_phrase(self): + analysis = analyze_response(":status: 599\ncontent-type: text/plain\n\nx") + + assert (analysis.status.code, analysis.status.phrase, analysis.status.category) == ( + 599, "", "Server Error") + + def test_a_registered_code_keeps_its_registered_phrase(self): + assert analyze_response("HTTP/1.1 404 Nope\n\n").status.phrase == "Not Found" class TestHeaderParsing: @@ -78,6 +92,20 @@ def test_json_key_order_preserved(self): # Keys should appear in original order, not sorted. assert analysis.pretty_body.index('"z"') < analysis.pretty_body.index('"a"') + def test_numbers_keep_their_text(self): + # json.loads made 1e400 the Infinity that is not JSON, and rounded the long one + analysis = analyze_response('{"n": 1e400, "big": 12345678901234567890.5}') + + assert '"n": 1e400' in analysis.pretty_body + assert '"big": 12345678901234567890.5' in analysis.pretty_body + + def test_a_repeated_key_is_not_laid_out_as_json(self): + # It kept the last value without a word, and JSON Format then refused the body + analysis = analyze_response('{"a": 1, "a": 2}') + + assert not analysis.is_json_body + assert analysis.pretty_body is None + def test_body_without_blank_line(self): # Headers then a JSON body with no separating blank line. text = "HTTP/1.1 200 OK\nContent-Type: application/json\n{\"a\": 1}" @@ -120,3 +148,81 @@ def test_empty(self): assert analysis.status is None assert analysis.headers == {} assert analysis.body == "" + + +def test_a_body_nested_past_the_recursion_limit_is_not_json(): + analysis = analyze_response("HTTP/1.1 200 OK\n\n" + "[" * 100000 + "]" * 100000) + + assert analysis.pretty_body is None + + +def test_a_header_sent_twice_keeps_both_values(): + analysis = analyze_response( + "HTTP/1.1 200 OK\nSet-Cookie: a=1\nSet-Cookie: b=2\nContent-Type: text/plain\n\nok") + + assert analysis.headers == {"Set-Cookie": ["a=1", "b=2"], "Content-Type": "text/plain"} + + +class TestHeadersAsHttpDefinesThem: + def test_a_folded_line_continues_the_header_and_the_body_is_still_json(self): + # It ended the headers: the value was cut and the body no longer JSON + analysis = analyze_response('HTTP/1.1 200 OK\nX-Long: a\n b\n\n{"a": 1}') + + assert analysis.headers["X-Long"] == "a b" + assert analysis.is_json_body + + def test_names_differing_only_in_case_are_one_header(self): + analysis = analyze_response("HTTP/1.1 200 OK\nSet-Cookie: a=1\nset-cookie: b=2\n\n") + + assert analysis.headers == {"Set-Cookie": ["a=1", "b=2"]} + + def test_an_http2_status_pseudo_header_gives_the_status_and_keeps_the_headers(self): + # It ended the headers, and everything after it was read as the body + analysis = analyze_response(':status: 404\ncontent-type: application/json\n\n{"a": 1}') + + assert analysis.status is not None and analysis.status.code == 404 + assert analysis.headers == {"content-type": "application/json"} + assert analysis.is_json_body + + @pytest.mark.parametrize("code", ["²", "٤٠٤", "4 0 4"]) + def test_a_status_that_is_not_ascii_digits_is_no_status(self, code): + # "²".isdigit() holds, and int() raised ValueError out of the tab + analysis = analyze_response(f":status: {code}\ncontent-type: text/plain\n\nbody") + + assert analysis.status is None + assert analysis.headers == {"content-type": "text/plain"} + + +class TestCurlShowsEveryResponse: + """curl -i prints interim and earlier responses before the final one.""" + + @pytest.mark.parametrize("first", [ + "HTTP/1.1 100 Continue\n\n", + "HTTP/1.1 200 Connection established\n\n", + "HTTP/1.1 301 Moved Permanently\nLocation: /new\n\n", + ]) + def test_the_final_response_is_the_one_read(self, first): + # The first status line was taken and the real response read as its body + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + result = analyze_response(first + 'HTTP/1.1 404 Not Found\nContent-Type: application/json\n\n{"a": 1}') + + assert result.status.code == 404 + assert result.headers == {"Content-Type": "application/json"} + assert result.is_json_body + + def test_a_body_alone_is_not_a_header(self): + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + result = analyze_response("Error: invalid token supplied") + + assert result.headers == {} + assert result.body == "Error: invalid token supplied" + + def test_a_header_block_is_still_headers(self): + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + result = analyze_response("Content-Type: text/plain\nX-Id: 7\n\nhello") + + assert result.headers == {"Content-Type": "text/plain", "X-Id": "7"} + assert result.body == "hello" diff --git a/test/test_utils/test_response_inspector_gui.py b/test/test_utils/test_response_inspector_gui.py index a8cd29bc..1ba8ad2d 100644 --- a/test/test_utils/test_response_inspector_gui.py +++ b/test/test_utils/test_response_inspector_gui.py @@ -197,6 +197,18 @@ def test_repeated_headers_survive_the_hand_off(self, widget_with_window): opened = window.tab_widget.added[0][0] assert "set-cookie × 2" in opened.output_edit.toPlainText() + def test_the_analysed_headers_are_handed_over_not_newer_input(self, widget_with_window): + # The other buttons used the analysis; this one read the input box again + gui, window = widget_with_window + gui.input_edit.setPlainText("HTTP/1.1 200 OK\nServer: analysed/1\n\n{}") + gui.analyze() + gui.input_edit.setPlainText("HTTP/1.1 200 OK\nServer: pasted-later/2\n\n{}") + + gui.open_headers_in_analyzer() + + report = window.tab_widget.added[0][0].output_edit.toPlainText() + assert "analysed/1" in report and "pasted-later/2" not in report + def test_open_headers_before_analyze_is_noop(self, widget_with_window): gui, window = widget_with_window assert gui.open_headers_in_analyzer() is None @@ -238,3 +250,13 @@ def test_open_body_without_json_is_noop(self, widget_with_window): def test_open_body_before_analyze_is_noop(self, widget_with_window): gui, _window = widget_with_window assert gui.open_body_in_json_format() is None + + +def test_the_report_lists_every_value_of_a_repeated_header(app): + from pybreeze.pybreeze_ui.tools_gui.response_inspector_gui import build_report_text + from pybreeze.utils.response_inspector.response_analyzer import analyze_response + + text = build_report_text(analyze_response( + "HTTP/1.1 200 OK\nSet-Cookie: a=1\nSet-Cookie: b=2\n\nok")) + + assert "Set-Cookie: a=1" in text and "Set-Cookie: b=2" in text diff --git a/test/test_utils/test_run_folder.py b/test/test_utils/test_run_folder.py new file mode 100644 index 00000000..6d7d02be --- /dev/null +++ b/test/test_utils/test_run_folder.py @@ -0,0 +1,69 @@ +"""Running a folder of action files: which files, and what the user hears when there are none.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox + +from pybreeze.extend.process_executor import process_executor_utils +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + +@pytest.fixture(scope="module") +def window(): + QApplication.instance() or QApplication([]) + update_language_dict() + made = QMainWindow() + yield made + made.deleteLater() + + +@pytest.fixture() +def picked(monkeypatch): + """The folder the dialog returns, and the parents it was opened with.""" + state = {"folder": "", "parents": [], "told": []} + + def pick(parent, *_args, **_kwargs): + state["parents"].append(parent) + return state["folder"] + + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(pick)) + monkeypatch.setattr( + QMessageBox, "information", staticmethod(lambda *args: state["told"].append(args))) + return state + + +def test_the_dialog_belongs_to_the_main_window(window, picked): + process_executor_utils._ask_for_action_files(window) + + # It was opened through a throwaway instance, so it had no parent. + assert picked["parents"] == [window] + + +def test_cancelling_runs_nothing_and_says_nothing(window, picked): + assert process_executor_utils._ask_for_action_files(window) == [] + assert picked["told"] == [] + + +def test_the_json_files_under_the_folder_are_returned(window, picked, tmp_path): + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "login.json").write_text("[]", encoding="utf-8") + (tmp_path / "notes.txt").write_text("", encoding="utf-8") + picked["folder"] = str(tmp_path) + + files = process_executor_utils._ask_for_action_files(window) + + assert [os.path.basename(file) for file in files] == ["login.json"] + assert picked["told"] == [] + + +def test_a_folder_with_nothing_to_run_says_so(window, picked, tmp_path): + picked["folder"] = str(tmp_path) + + assert process_executor_utils._ask_for_action_files(window) == [] + # It used to do nothing at all. + (told,) = picked["told"] + assert str(tmp_path) in told[2] diff --git a/test/test_utils/test_run_output.py b/test/test_utils/test_run_output.py new file mode 100644 index 00000000..14d2c52e --- /dev/null +++ b/test/test_utils/test_run_output.py @@ -0,0 +1,625 @@ +"""A child's output, end to end: pipe, reader thread, queue, timer pump, run window.""" +from __future__ import annotations + +import gc +import json +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + +# A child process on a busy CI runner can take a while to start. +_RUN_TIMEOUT_SECONDS = 30 + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if app is None: + try: + app = QApplication([]) + except Exception as exc: # pragma: no cover - no usable Qt platform + pytest.skip(f"Cannot start QApplication: {exc}") + return app + + +def _run_events_until(qt_app, finished) -> None: + deadline = time.monotonic() + _RUN_TIMEOUT_SECONDS + while not finished(): + if time.monotonic() > deadline: + pytest.fail("the child process did not finish in time") + qt_app.processEvents() + time.sleep(0.01) + + +def _finished_after_collecting(run_window, last_line: str): + """Return a check that collects garbage, then looks for *last_line* in *run_window*. + + Collecting on every check is what a long-running IDE does sooner or later: + whatever keeps the run going must survive it. + """ + def finished() -> bool: + gc.collect() + return last_line in run_window.code_result.toPlainText() + return finished + + +class MainWindow: + """The little of the IDE's main window that opening a run window touches.""" + + def __init__(self, python_compiler=None) -> None: + self.python_compiler = python_compiler + self.current_run_code_window: list = [] + self.encoding = "utf-8" + + def clear_code_result(self) -> None: + """Nothing to clear in a stand-in.""" + + +class TestTaskProcessOutput: + def test_indented_output_keeps_its_indentation(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + data = tmp_path / "data.json" + data.write_text(json.dumps({"outer": {"inner": 1}}), encoding="utf-8") + process = build_task_process(MainWindow(sys.executable)) + + process.start_module_process("json.tool", [str(data)]) + _run_events_until(qt_app, lambda: process.process is None) + + text = process.main_window.code_result.toPlainText() + assert '{\n "outer": {\n "inner": 1\n }\n}\n' in text + assert text.endswith("Task exit with code 0\n") + + def test_the_run_finishes_with_no_one_else_holding_its_manager(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + data = tmp_path / "data.json" + data.write_text(json.dumps({"key": "value"}), encoding="utf-8") + main_window = MainWindow(sys.executable) + + build_task_process(main_window).start_module_process("json.tool", [str(data)]) + run_window = main_window.current_run_code_window[0] + _run_events_until( + qt_app, _finished_after_collecting(run_window, "Task exit with code 0")) + + assert '"key": "value"' in run_window.code_result.toPlainText() + + +class TestFileRunnerOutput: + def test_indentation_and_blank_lines_survive(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "show.py" + script.write_text( + "import sys\n" + "print('def f():')\n" + "print(' return 1')\n" + "print()\n" + "print(' warned', file=sys.stderr)\n", + encoding="utf-8", + ) + window = CodeWindow() + runner = FileRunnerProcess(window) + + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + _run_events_until(qt_app, lambda: runner.process is None) + + text = window.code_result.toPlainText() + assert "def f():\n return 1\n\n" in text + assert " warned\n" in text + assert text.endswith("[Process exited with code 0]\n") + + def test_a_program_that_reads_input_gets_end_of_file(self, qt_app, tmp_path): + # A run window has no input box: a read must end at once rather than + # wait on a pipe nobody writes to, which hung the run for good. + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "ask.py" + script.write_text("input('name? ')\n", encoding="utf-8") + window = CodeWindow() + window.runner = FileRunnerProcess(window) + + window.runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + _run_events_until(qt_app, lambda: window.runner.process is None) + + text = window.code_result.toPlainText() + assert "EOFError" in text + assert text.endswith("[Process exited with code 1]\n") + + def test_run_with_finishes_with_no_one_else_holding_its_runner( + self, qt_app, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with + + script = tmp_path / "hello.py" + script.write_text("print('hello from the plugin run')\n", encoding="utf-8") + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + main_window = MainWindow() + + run_with.run_current_file_with( + main_window, {"name": "Python", "compiler": sys.executable, "suffixes": (".py",)}) + run_window = main_window.current_run_code_window[0] + _run_events_until( + qt_app, _finished_after_collecting(run_window, "[Process exited with code 0]")) + + assert "hello from the plugin run" in run_window.code_result.toPlainText() + + +_SLEEP_SECONDS = "60" + + +class TestStoppingARun: + """Stopping the child a run window shows: what closing the IDE does to every run.""" + + def test_a_python_run_is_stopped_and_reports_its_exit(self, qt_app): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + main_window = MainWindow(sys.executable) + manager = build_task_process(main_window) + manager.start_module_process( + "timeit", ["-n", "1", "-r", "1", f"import time; time.sleep({_SLEEP_SECONDS})"]) + child = manager.process + run_window = main_window.current_run_code_window[0] + + run_window.stop_runner() + _run_events_until( + qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + assert child.poll() is not None + + def test_a_plugin_run_is_stopped_and_reports_its_exit(self, qt_app, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.menu.plugin_menu import build_run_with_menu as run_with + + script = tmp_path / "wait.py" + script.write_text(f"import time\ntime.sleep({_SLEEP_SECONDS})\n", encoding="utf-8") + monkeypatch.setattr(run_with, "save_current_file_for_run", lambda _w: str(script)) + main_window = MainWindow() + + run_with.run_current_file_with( + main_window, {"name": "Python", "compiler": sys.executable, "suffixes": (".py",)}) + run_window = main_window.current_run_code_window[0] + child = run_window.runner.process + run_window.stop_runner() + _run_events_until( + qt_app, lambda: "[Process exited with code" in run_window.code_result.toPlainText()) + + assert child.poll() is not None + + def test_stopping_a_finished_run_changes_nothing(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + data = tmp_path / "data.json" + data.write_text("{}", encoding="utf-8") + main_window = MainWindow(sys.executable) + manager = build_task_process(main_window) + manager.start_module_process("json.tool", [str(data)]) + _run_events_until(qt_app, lambda: manager.process is None) + run_window = main_window.current_run_code_window[0] + before = run_window.code_result.toPlainText() + + run_window.stop_runner() + + assert run_window.code_result.toPlainText() == before + + def test_a_window_that_never_ran_anything_can_be_stopped(self, qt_app): + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + CodeWindow().stop_runner() + + +class TestTestPioneerRun: + def test_runs_the_chosen_yaml_through_the_task_manager(self, monkeypatch): + from pybreeze.extend.process_executor.test_pioneer import test_pioneer_process_manager + + calls: list = [] + + class Recorder: + def start_module_process(self, package, arguments, environment=None): + calls.append((package, list(arguments), environment)) + + monkeypatch.setattr( + test_pioneer_process_manager, "build_task_process", + lambda main_window, program_buffer: Recorder()) + + test_pioneer_process_manager.init_and_start_test_pioneer_process( + MainWindow(), "C:/tests/run.yml") + + assert calls == [("test_pioneer", ["-e", "C:/tests/run.yml"], None)] + + def test_no_interpreter_is_reported_not_raised(self, qt_app, monkeypatch): + from je_editor import JEditorExecException + + from pybreeze.extend.process_executor import python_task_process_manager as manager_module + from pybreeze.extend.process_executor.test_pioneer import test_pioneer_process_manager + + def no_python(_path): + raise JEditorExecException("no python interpreter found") + + monkeypatch.setattr(sys, "frozen", True, raising=False) # only a packaged build can find none + monkeypatch.setattr(manager_module, "check_and_choose_venv", no_python) + main_window = MainWindow() + + test_pioneer_process_manager.init_and_start_test_pioneer_process(main_window, "run.yml") + + run_window = main_window.current_run_code_window[0] + assert "No Python interpreter found" in run_window.code_result.toPlainText() + + +class TestAPythonRunThatCannotStart: + def test_a_missing_interpreter_is_reported_in_the_window(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + main_window = MainWindow(python_compiler=str(tmp_path / "gone" / "python.exe")) + + # It raised FileNotFoundError out of the menu, and the run window was + # never shown. + build_task_process(main_window).start_module_process("pip", ["--version"]) + + run_window = main_window.current_run_code_window[0] + assert "pip could not start" in run_window.code_result.toPlainText() + assert run_window.runner.process is None + + def test_a_script_that_reads_input_gets_end_of_file(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + (tmp_path / "ask.py").write_text("input('name? ')\n", encoding="utf-8") + main_window = MainWindow(python_compiler=sys.executable) + runner = build_task_process(main_window) + + # It read the IDE's own console, and waited there. + runner.start_module_process("ask", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + assert "EOFError" in run_window.code_result.toPlainText() + + +class TestAFileRunThatCannotStart: + def test_a_compiler_that_is_a_folder_is_reported(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + window = CodeWindow() + runner = FileRunnerProcess(window) + + # PermissionError: only FileNotFoundError was caught. + runner.run_file({"name": "Odd", "compiler": str(tmp_path)}, str(tmp_path / "main.x")) + + assert "[Error] Could not start" in window.code_result.toPlainText() + assert runner.process is None + + +def _script_leaving_a_process_on_the_pipes(folder, grandchild_code: str): + """A script that starts a process holding its output, prints that process's id, and exits.""" + script = folder / "leave.py" + script.write_text( + "import subprocess, sys\n" + f"child = subprocess.Popen([sys.executable, '-c', {grandchild_code!r}],\n" + " stdout=sys.stdout, stderr=sys.stderr)\n" + "print('grandchild', child.pid, flush=True)\n", + encoding="utf-8", + ) + return script + + +def _longest_event_pass(qt_app, finished) -> float: + """Process events until *finished*, returning the longest single pass in seconds.""" + deadline = time.monotonic() + _RUN_TIMEOUT_SECONDS + longest = 0.0 + while not finished(): + if time.monotonic() > deadline: + pytest.fail("the run did not end in time") + started = time.monotonic() + qt_app.processEvents() + longest = max(longest, time.monotonic() - started) + time.sleep(0.01) + return longest + + +def _stop_grandchild(text: str) -> None: + for line in text.splitlines(): + if line.startswith("grandchild "): + try: + os.kill(int(line.split()[1]), 9) + except OSError: + pass + + +# Long enough to outlast the run's grace many times over +_HOLDING = "import time; time.sleep(20)" + + +class TestAProcessTheRunStartedHoldsItsOutput: + """The run ends without waiting on the UI thread for pipes a grandchild keeps open.""" + + def test_a_python_run_ends_without_freezing_the_window(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + _script_leaving_a_process_on_the_pipes(tmp_path, _HOLDING) + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "leave", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + try: + # Each reader was joined for two seconds inside one timer tick + longest = _longest_event_pass( + qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + text = run_window.code_result.toPlainText() + finally: + _stop_grandchild(run_window.code_result.toPlainText()) + + assert longest < 1.0 + assert "still holds its output" in text + + def test_a_plugin_run_ends_without_freezing_the_window(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = _script_leaving_a_process_on_the_pipes(tmp_path, _HOLDING) + window = CodeWindow() + runner = FileRunnerProcess(window) + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + try: + longest = _longest_event_pass(qt_app, lambda: not runner.still_running) + text = window.code_result.toPlainText() + finally: + _stop_grandchild(window.code_result.toPlainText()) + + assert longest < 1.0 + assert "still holds its output" in text + assert "[Process exited with code 0]" in text + + def test_output_that_comes_within_the_grace_is_shown(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + _script_leaving_a_process_on_the_pipes( + tmp_path, "import time; time.sleep(0.3); print('late line', flush=True)") + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "leave", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + text = run_window.code_result.toPlainText() + assert text.index("late line") < text.index("Task exit with code") + assert "still holds its output" not in text + + +def test_a_runaway_run_holds_a_bounded_backlog_and_stops_promptly(qt_app, tmp_path): + # The queues were unbounded: a print loop filled memory while it ran, and + # Stop then poured the whole backlog into the window on the UI thread + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.extend.process_executor.queue_pump import MAX_QUEUED_MESSAGES + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + script = tmp_path / "flood.py" + script.write_text("i = 0\nwhile True:\n print(i)\n i += 1\n", encoding="utf-8") + window = CodeWindow() + runner = FileRunnerProcess(window) + runner.run_file({"name": "Python", "compiler": sys.executable}, str(script)) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.01) + + assert runner.output_queue.qsize() <= MAX_QUEUED_MESSAGES + runner.stop() + _run_events_until(qt_app, lambda: not runner.still_running) + assert "[Process exited with code" in window.code_result.toPlainText() + + +def test_a_line_without_its_newline_shows_while_the_run_goes_on(qt_app, tmp_path): + # Read by line, a status line or a progress bar showed nothing until the + # run's next newline + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + (tmp_path / "status.py").write_text( + "import sys, time\n" + "sys.stdout.write('working... 50%'); sys.stdout.flush()\n" + "time.sleep(4)\n" + "print(' done')\n", + encoding="utf-8") + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "status", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + + _run_events_until(qt_app, lambda: "50%" in run_window.code_result.toPlainText()) + assert "done" not in run_window.code_result.toPlainText() + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + assert "working... 50% done" in run_window.code_result.toPlainText() + + +def _fake_compiler(folder: Path, seconds: float = 0.0) -> Path: + """A "compiler" that sleeps *seconds*, then copies a stand-alone Windows program to the -o path.""" + script = folder / "fakecc.py" + script.write_text( + "import shutil, sys, time\n" + f"time.sleep({seconds})\n" + "shutil.copy(r'C:\\Windows\\System32\\hostname.exe', sys.argv[sys.argv.index('-o') + 1])\n" + "print('compiled', flush=True)\n", + encoding="utf-8") + return script + + +def _compile_config(script: Path) -> dict: + return {"name": "Fake", "compiler": sys.executable, "args": (str(script),), "compile_then_run": True} + + +@pytest.mark.skipif(os.name != "nt", reason="the stand-in program is Windows'") +class TestCompileThenRun: + def test_the_binary_is_built_away_from_the_source_and_removed(self, qt_app, tmp_path): + # It was built beside the source, replacing and then deleting a file of + # that name there + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + source = tmp_path / "hello.c" + source.write_text("int main(){}", encoding="utf-8") + mine = tmp_path / "hello.exe" + mine.write_bytes(b"the user's own build") + window = CodeWindow() + runner = FileRunnerProcess(window) + window.runner = runner + + runner.run_file(_compile_config(_fake_compiler(tmp_path)), str(source)) + _run_events_until(qt_app, lambda: "[Process exited with code" in window.code_result.toPlainText()) + + text = window.code_result.toPlainText() + assert "[Process exited with code 0]" in text + assert mine.read_bytes() == b"the user's own build" + built = next(line for line in text.splitlines() if line.startswith("[Run] "))[len("[Run] "):] + assert Path(built).parent != tmp_path + assert not Path(built).parent.exists() + + def test_stop_during_the_compile_is_reported_and_runs_nothing(self, qt_app, tmp_path): + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + source = tmp_path / "slow.c" + source.write_text("int main(){}", encoding="utf-8") + window = CodeWindow() + runner = FileRunnerProcess(window) + window.runner = runner + ended: list = [] + window.run_ended = lambda: ended.append(True) + + runner.run_file(_compile_config(_fake_compiler(tmp_path, seconds=20)), str(source)) + _run_events_until(qt_app, lambda: runner.process is not None) + runner.stop() + _run_events_until(qt_app, lambda: ended) + + text = window.code_result.toPlainText() + # It said "[Compile failed] exit code 1" + assert "[Stopped]" in text + assert "[Compile failed]" not in text and "[Run]" not in text + + def test_stop_just_after_the_compile_runs_nothing(self, qt_app, tmp_path): + # Once the compiler had exited, Stop found nothing to stop and the binary ran + from pybreeze.extend.process_executor.file_runner_process import FileRunnerProcess + from pybreeze.pybreeze_ui.show_code_window.code_window import CodeWindow + + source = tmp_path / "quick.c" + source.write_text("int main(){}", encoding="utf-8") + window = CodeWindow() + runner = FileRunnerProcess(window) + window.runner = runner + ended: list = [] + window.run_ended = lambda: ended.append(True) + + runner.run_file(_compile_config(_fake_compiler(tmp_path)), str(source)) + runner.process.wait(30) + runner.stop() + _run_events_until(qt_app, lambda: ended) + + text = window.code_result.toPlainText() + assert "[Stopped]" in text and "[Run]" not in text + + +def _logging_package(folder: Path, log: Path, seconds: float) -> None: + """A stand-in automation package: logs when it starts and ends on the file it is given.""" + package = folder / "fakepkg" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "__main__.py").write_text( + "import sys, time\n" + f"log = {str(log)!r}\n" + "name = sys.argv[sys.argv.index('--execute_file') + 1]\n" + "open(log, 'a', encoding='utf-8').write(f'start {name}\\n')\n" + f"time.sleep({seconds})\n" + "open(log, 'a', encoding='utf-8').write(f'end {name}\\n')\n", + encoding="utf-8") + + +class TestRunningAFolder: + """The files of a folder run one after another: their reports went to one file.""" + + def _files(self, folder: Path, count: int) -> list[str]: + files = [] + for index in range(count): + path = folder / f"case{index}.json" + path.write_text("[]", encoding="utf-8") + files.append(str(path)) + return files + + def test_each_file_starts_after_the_one_before_has_ended(self, qt_app, tmp_path, monkeypatch): + from pybreeze.extend.process_executor.process_executor_utils import run_one_after_another + + log = tmp_path / "log.txt" + _logging_package(tmp_path, log, seconds=0.5) + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + main_window = MainWindow(python_compiler=sys.executable) + files = self._files(tmp_path, 3) + + run_one_after_another(main_window, "fakepkg", files) + _run_events_until(qt_app, lambda: log.exists() and log.read_text(encoding="utf-8").count("end") == 3) + + events = log.read_text(encoding="utf-8").split() + assert events == [word for name in files for word in ("start", name, "end", name)] + assert len(main_window.current_run_code_window) == 3 + + def test_a_stopped_run_ends_the_batch(self, qt_app, tmp_path, monkeypatch): + from pybreeze.extend.process_executor.process_executor_utils import run_one_after_another + + log = tmp_path / "log.txt" + _logging_package(tmp_path, log, seconds=20) + monkeypatch.setenv("PYTHONPATH", str(tmp_path)) + main_window = MainWindow(python_compiler=sys.executable) + + run_one_after_another(main_window, "fakepkg", self._files(tmp_path, 3)) + first = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: log.exists()) + first.stop_runner() + _run_events_until(qt_app, lambda: "Task exit with code" in first.code_result.toPlainText()) + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + qt_app.processEvents() + time.sleep(0.05) + + assert len(main_window.current_run_code_window) == 1 + + +def _is_running(pid: int) -> bool: + """Whether a process with *pid* is still there (not os.kill: on Windows that ends it).""" + import subprocess + + listed = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"], capture_output=True, + text=True, timeout=30, check=False) + return str(pid) in listed.stdout + + +@pytest.mark.skipif(os.name != "nt", reason="checked with tasklist") +def test_stop_ends_what_the_run_started_too(qt_app, tmp_path): + # A launcher's program (go run, cargo run) or a web run's browser was left + # running: only the direct child was stopped + from pybreeze.extend.process_executor.process_executor_utils import build_task_process + + script = tmp_path / "launcher.py" + script.write_text( + "import subprocess, sys, time\n" + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" + "print('grandchild', child.pid, flush=True)\n" + "time.sleep(60)\n", + encoding="utf-8") + main_window = MainWindow(python_compiler=sys.executable) + build_task_process(main_window).start_module_process( + "launcher", [], environment={"PYTHONPATH": str(tmp_path)}) + run_window = main_window.current_run_code_window[0] + _run_events_until(qt_app, lambda: "grandchild" in run_window.code_result.toPlainText()) + line = next(text for text in run_window.code_result.toPlainText().splitlines() if text.startswith("grandchild")) + grandchild = int(line.split()[1]) + try: + run_window.stop_runner() + _run_events_until(qt_app, lambda: "Task exit with code" in run_window.code_result.toPlainText()) + + assert not _is_running(grandchild) + finally: + _stop_grandchild(run_window.code_result.toPlainText()) + diff --git a/test/test_utils/test_script_templates.py b/test/test_utils/test_script_templates.py index 239d1367..a740e2a1 100644 --- a/test/test_utils/test_script_templates.py +++ b/test/test_utils/test_script_templates.py @@ -3,6 +3,8 @@ import json +import pytest + from pybreeze.utils.curl_import.curl_parser import parse_curl from pybreeze.utils.curl_import.request_body import body_kind, form_parts from pybreeze.utils.curl_import.request_codegen import to_requests_code @@ -36,6 +38,28 @@ def test_json_content_type_but_invalid_body_is_data(self): request = parse_curl("curl -H 'Content-Type: application/json' -d 'not json' https://x") assert body_kind(request) == ("data", "not json") + @pytest.mark.parametrize("body", [ + '{"a": 1, "a": 2}', # the object kept only the last + '{"p": 0.10000000000000000001}', # the float wrote back 0.1 + '{"x": 1e400}', # inf + '[NaN]', # not JSON + "[" * 5000 + "]" * 5000, # RecursionError out of the tab + "[" * 102 + "]" * 102, # too deep to write as a literal + ], ids=["repeated-key", "long-float", "inf", "nan", "past-the-parser", "past-a-literal"]) + def test_a_body_the_object_would_not_send_back_as_it_was_goes_raw(self, body): + request = parse_curl(f"curl -H 'Content-Type: application/json' -d '{body}' https://x") + + assert body_kind(request) == ("data", body) + for target, _label in TEMPLATE_TARGETS: + generate_template(target, request) + compile(to_requests_code(request), "generated", "exec") + + def test_numbers_a_float_holds_still_go_as_json(self): + request = parse_curl( + "curl -H 'Content-Type: application/json' -d '{\"p\": 0.1, \"q\": 1E3, \"n\": 12345678901234567890}' https://x") + + assert body_kind(request) == ("json", {"p": 0.1, "q": 1000.0, "n": 12345678901234567890}) + class TestFormParts: def test_plain_field(self): @@ -59,10 +83,12 @@ def test_mixed(self): class TestRequestsCodeForm: - def test_form_uses_data_and_files(self): + def test_every_form_field_goes_in_files(self): + # data= made requests send a text-only form URL-encoded; curl sends multipart code = to_requests_code(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up")) - assert "data=data" in code + assert "data=data" not in code assert "files=files" in code + assert '"name": (None, "x"),' in code assert 'open("a.jpg", "rb")' in code def test_form_code_is_valid_python(self): @@ -81,8 +107,9 @@ def test_json_flag_uses_json_kwarg(self): assert "json_body = {" in code def test_data_file_reads_file(self): + # As curl does for -d: the bytes, without carriage returns and newlines code = to_requests_code(parse_curl("curl -d @body.json https://x")) - assert 'data = open("body.json", encoding="utf-8").read()' in code + assert 'data = open("body.json", "rb").read().replace(b"\\r", b"").replace(b"\\n", b"")' in code assert "data=data" in code def test_data_file_code_is_valid_python(self): @@ -90,7 +117,7 @@ def test_data_file_code_is_valid_python(self): def test_apitestka_python_data_file(self): code = to_apitestka_python(parse_curl("curl -d @body.json https://x")) - assert 'open("body.json", encoding="utf-8").read()' in code + assert 'open("body.json", "rb").read()' in code compile(code, "", "exec") @@ -137,10 +164,10 @@ def test_is_valid_python(self): ) compile(to_apitestka_python(parse_curl(command)), "", "exec") - def test_form_data_and_files(self): + def test_form_fields_and_files_go_in_files(self): code = to_apitestka_python(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up")) - assert "data=" in code - assert "files=" in code + assert "data=" not in code + assert '"name": (None, "x")' in code assert 'open("a.jpg", "rb")' in code compile(code, "", "exec") @@ -178,11 +205,22 @@ def test_get_flag_params(self): action = json.loads(to_apitestka_action_json(parse_curl("curl -G https://x -d 'a=1'"))) assert action[0][1]["params"] == {"a": "1"} - def test_form_data_fields_included(self): - action = json.loads( - to_apitestka_action_json(parse_curl("curl -F 'name=x' -F 'photo=@a.jpg' https://up"))) - # Plain form fields go under "data"; file uploads are omitted (no file handles in JSON). - assert action[0][1]["data"] == {"name": "x"} + def test_form_text_fields_are_sent_as_multipart(self): + action = json.loads(to_apitestka_action_json(parse_curl("curl -F 'name=x' -F 'a=b' https://up"))) + # [null, text] is requests' (None, text): a multipart field, as curl sends it + assert action[0][1]["files"] == {"name": [None, "x"], "a": [None, "b"]} + assert "data" not in action[0][1] + + @pytest.mark.parametrize("command", [ + "curl -F 'name=x' -F 'photo=@a.jpg' https://up", + "curl -d @body.json -H 'Content-Type: application/json' https://up", + ]) + def test_a_file_it_cannot_open_is_refused_not_left_out(self, command): + # The upload, or the body read from a file, was left out without a word + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException): + to_apitestka_action_json(parse_curl(command)) class TestLoadDensityPython: @@ -288,3 +326,98 @@ def test_apitestka_action_target(self): def test_unknown_target_falls_back_to_requests(self): code = generate_template("nope", parse_curl("curl https://x")) assert "import requests" in code + + +class TestMethodsInGeneratedCode: + def test_a_method_that_is_not_a_token_is_refused(self): + import pytest + + from pybreeze.utils.exception.exceptions import CurlParseException + + with pytest.raises(CurlParseException, match="not an HTTP method"): + parse_curl("curl -X 'GET():\n import os\ndef t' https://x/a") + + def test_a_method_with_a_hyphen_makes_a_valid_test_name(self): + import ast + + from pybreeze.utils.curl_import.script_templates import to_pytest_test + + code = to_pytest_test(parse_curl("curl -X M-SEARCH https://x/a")) + + assert "def test_m_search_a():" in code + ast.parse(code) + + +class TestJsonBodiesInGeneratedPython: + """A JSON body is written as Python: true/false/null would be undefined names.""" + + _COMMAND = ( + "curl https://x/api -H 'Content-Type: application/json' " + "-d '{\"a\": true, \"b\": null, \"c\": [1.5, {\"d\": false}], \"e\": \"x\"}'" + ) + + def test_every_python_target_is_free_of_json_names(self): + import ast + + from pybreeze.utils.curl_import.script_templates import generate_template + + request = parse_curl(self._COMMAND) + for target in ("requests", "pytest", "apitestka_python"): + tree = ast.parse(generate_template(target, request)) + names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)} + assert not names & {"true", "false", "null"}, target + + def test_the_body_evaluates_to_what_was_sent(self): + import ast + + code = to_requests_code(parse_curl(self._COMMAND)) + assignment = next( + node for node in ast.parse(code).body + if isinstance(node, ast.Assign) and node.targets[0].id == "json_body") + + assert ast.literal_eval(assignment.value) == { + "a": True, "b": None, "c": [1.5, {"d": False}], "e": "x"} + + +def test_python_literal_round_trips_any_json_value(): + import ast + + from hypothesis import given, settings + from hypothesis import strategies as st + + from pybreeze.utils.curl_import.request_codegen import python_literal + + scalars = st.none() | st.booleans() | st.integers() | st.floats(allow_nan=False, allow_infinity=False) | st.text() + values = st.recursive( + scalars, + lambda children: st.lists(children, max_size=4) + | st.dictionaries(st.text(), children, max_size=4), + max_leaves=20) + + @settings(max_examples=200, deadline=None) + @given(values, st.booleans()) + def round_trip(value, inline): + assert ast.literal_eval(python_literal(value, inline=inline)) == value + + round_trip() + + +def test_a_float_json_allows_but_python_cannot_write_is_spelled_out(): + from pybreeze.utils.curl_import.request_codegen import python_literal + + assert python_literal(float("inf")) == 'float("inf")' + assert python_literal([float("-inf")], inline=True) == '[float("-inf")]' + assert python_literal(float("nan")) == 'float("nan")' + + +def test_a_character_outside_the_bmp_stays_one_character(): + import ast + + from pybreeze.utils.curl_import.request_codegen import python_string + + text = "h\u00e9llo \U0001F600 \U00020000" + assert ast.literal_eval(python_string(text)) == text + assert "\\ud83d" not in python_string("\U0001F600") + lone = "lone \ud800" + assert ast.literal_eval(python_string(lone)) == lone + diff --git a/test/test_utils/test_sftp_cancel.py b/test/test_utils/test_sftp_cancel.py new file mode 100644 index 00000000..afb01ada --- /dev/null +++ b/test/test_utils/test_sftp_cancel.py @@ -0,0 +1,156 @@ +"""A transfer can be cancelled: what had arrived goes, and a file it was to replace stays.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import errno +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod + +WAIT_SECONDS = 10 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class ChunkedSftp: + """Moves a file in blocks, reporting each as paramiko does, until told the whole is there.""" + + def __init__(self, files: dict[str, bytes] | None = None, blocks: int = 1000) -> None: + self.files = dict(files or {}) + self.blocks = blocks + + def stat(self, path: str): + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + return object() + + def get(self, remote: str, local: str, callback=None) -> None: + with open(local, "wb") as target: + for block in range(self.blocks): + target.write(b"x") + if callback is not None: + callback(block + 1, self.blocks) + time.sleep(0.001) + + def put(self, _local: str, remote: str, callback=None) -> None: + self.files[remote] = b"" + for block in range(self.blocks): + self.files[remote] += b"x" + if callback is not None: + callback(block + 1, self.blocks) + time.sleep(0.001) + + def posix_rename(self, old: str, new: str) -> None: + self.files[new] = self.files.pop(old) + + def remove(self, path: str) -> None: + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + del self.files[path] + + def close(self) -> None: + """Nothing to close.""" + + +def _wrapper(sftp: ChunkedSftp) -> sftp_session.SFTPClientWrapper: + wrapper = sftp_session.SFTPClientWrapper() + wrapper._ssh = SimpleNamespace(close=lambda: None) + wrapper._sftp = sftp + return wrapper + + +def _cancel_once_under_way(app, thread) -> list[str]: + """Start *thread*, cancel it after its first blocks, and return which signals it sent.""" + sent: list[str] = [] + thread.done.connect(lambda _path: sent.append("done")) + thread.failed.connect(lambda _message: sent.append("failed")) + thread.cancelled.connect(lambda: sent.append("cancelled")) + thread.start() + time.sleep(0.05) + thread.cancel() + assert thread.wait(WAIT_SECONDS * 1000), "the transfer did not stop" + app.processEvents() + return sent + + +class TestCancellingADownload: + def test_removes_what_arrived_and_keeps_the_old_file(self, app, tmp_path): + target = tmp_path / "report.csv" + target.write_bytes(b"old report") + thread = sftp_session.SftpTransferThread( + _wrapper(ChunkedSftp()), True, "/srv/report.csv", str(target)) + + sent = _cancel_once_under_way(app, thread) + + assert sent == ["cancelled"] + assert target.read_bytes() == b"old report" + assert [path.name for path in tmp_path.iterdir()] == ["report.csv"] + + +class TestCancellingAnUpload: + def test_removes_what_arrived_and_keeps_the_servers_copy(self, app, tmp_path): + local = tmp_path / "config.yaml" + local.write_bytes(b"new config") + sftp = ChunkedSftp({"/etc/app/config.yaml": b"old config"}) + thread = sftp_session.SftpTransferThread( + _wrapper(sftp), False, "/etc/app/config.yaml", str(local), replace=True) + + sent = _cancel_once_under_way(app, thread) + + assert sent == ["cancelled"] + assert sftp.files == {"/etc/app/config.yaml": b"old config"} + + +class TestTheMenu: + def _menu_entries(self, widget, monkeypatch, choose_last: bool = False) -> list[str]: + shown: list[str] = [] + + class AnsweringMenu(tree_mod.QMenu): + def exec(self, _pos): + actions = [action for action in self.actions() if not action.isSeparator()] + shown.extend(action.text() for action in actions) + return actions[-1] if choose_last else None + + monkeypatch.setattr(tree_mod, "QMenu", AnsweringMenu) + monkeypatch.setattr(widget.tree, "itemAt", lambda _pos: None) + widget.on_context_menu(widget.tree.rect().center()) + return shown + + def test_offers_a_cancel_only_while_a_transfer_runs(self, app, monkeypatch, tmp_path): + word = tree_mod.language_wrapper.language_word_dict + cancel_label = word.get("ssh_file_viewer_context_menu_action_cancel_transfer") + shown_messages: list = [] + monkeypatch.setattr(QMessageBox, "information", lambda *args: shown_messages.append(args[1:3])) + widget = tree_mod.SSHFileTreeManager() + widget.client = _wrapper(ChunkedSftp(blocks=5000)) + assert cancel_label not in self._menu_entries(widget, monkeypatch) + + assert widget._start_transfer( + downloading=True, remote_path="/big.iso", local_path=str(tmp_path / "big.iso"), + title="Downloaded", message="Saved to") + assert self._menu_entries(widget, monkeypatch, choose_last=True)[-1] == cancel_label + + deadline = time.monotonic() + WAIT_SECONDS + while widget._transfer is not None: + assert time.monotonic() < deadline, "the transfer did not stop" + app.processEvents() + time.sleep(0.01) + assert shown_messages == [(word.get("ssh_file_viewer_dialog_title_transfer_cancelled"), + word.get("ssh_file_viewer_message_transfer_cancelled"))] + assert not (tmp_path / "big.iso").exists() + widget.close() + widget.deleteLater() diff --git a/test/test_utils/test_sftp_listing.py b/test/test_utils/test_sftp_listing.py new file mode 100644 index 00000000..ea04b53a --- /dev/null +++ b/test/test_utils/test_sftp_listing.py @@ -0,0 +1,209 @@ +"""Listing a remote directory runs off the UI thread and lands only where it is still wanted.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import stat +import threading +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtCore import QThread +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod +from pybreeze.pybreeze_ui.thread_keeper import is_kept + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _wait_for(condition) -> None: + deadline = time.monotonic() + WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + QApplication.processEvents() + time.sleep(0.01) + + +def _entry(name: str, directory: bool = False): + mode = stat.S_IFDIR if directory else stat.S_IFREG + return SimpleNamespace(filename=name, st_mode=mode, st_size=10) + + +class FakeClient: + """An open session whose listings wait for their path's gate to open.""" + + connected = True + + def __init__(self, listings: dict[str, list]) -> None: + self.listings = listings + self.gates: dict[str, threading.Event] = {} + self.listed_on: list[QThread] = [] + + def gate(self, path: str) -> threading.Event: + return self.gates.setdefault(path, threading.Event()) + + def list_dir(self, path: str): + self.listed_on.append(QThread.currentThread()) + self.gate(path).wait(WAIT_SECONDS) + result = self.listings[path] + if isinstance(result, Exception): + raise result + return result + + def close(self) -> None: + """Nothing to close.""" + + +def _tree(client: FakeClient): + widget = tree_mod.SSHFileTreeManager() + widget.client = client + return widget + + +def _names(item) -> list[str]: + return [item.child(i).text(0) for i in range(item.childCount())] + + +def _idle(widget) -> bool: + return not widget._listings + + +class TestListing: + def test_the_root_fills_from_a_thread(self, app): + client = FakeClient({"/": [_entry("b.txt"), _entry("etc", directory=True), _entry("a.txt")]}) + widget = _tree(client) + + widget.load_root("/") + root = widget.tree.topLevelItem(0) + assert _names(root) == [widget.word_dict.get("ssh_file_viewer_loading")] + client.gate("/").set() + _wait_for(lambda: _idle(widget) and _names(root) == ["etc", "a.txt", "b.txt"]) + + assert client.listed_on and client.listed_on[0] is not app.thread() + widget.close() + + def test_a_listing_that_returns_after_the_tree_is_cleared_is_dropped(self, app): + client = FakeClient({"/": [_entry("old.txt")]}) + widget = _tree(client) + + widget.load_root("/") + widget._disconnect() + client.gate("/").set() + _wait_for(lambda: _idle(widget)) + + assert widget.tree.topLevelItemCount() == 0 + widget.close() + + def test_a_refresh_supersedes_the_listing_before_it(self, app): + client = FakeClient({"/": [_entry("stale.txt")]}) + widget = _tree(client) + widget.load_root("/") + root = widget.tree.topLevelItem(0) + first_gate = client.gate("/") + + client.gates["/"] = threading.Event() # the refresh waits on a gate of its own + client.listings["/"] = [_entry("fresh.txt")] + widget.action_refresh(root) + client.gates["/"].set() + _wait_for(lambda: _names(root) == ["fresh.txt"]) + first_gate.set() + _wait_for(lambda: _idle(widget)) + + assert _names(root) == ["fresh.txt"] + widget.close() + + def test_a_failed_listing_is_reported(self, app, monkeypatch): + client = FakeClient({"/": OSError("permission denied")}) + client.gate("/").set() + widget = _tree(client) + shown = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args[2])) + + widget.load_root("/") + _wait_for(lambda: shown and _idle(widget)) + + assert "permission denied" in shown[0] + assert _names(widget.tree.topLevelItem(0)) == [] + widget.close() + + def test_closing_mid_listing_lets_it_run_out(self, app): + client = FakeClient({"/": [_entry("a.txt")]}) + widget = _tree(client) + + widget.load_root("/") + (listing,) = widget._listings + widget.close() + assert is_kept(listing) + client.gate("/").set() + _wait_for(lambda: not is_kept(listing)) + + +class TestLinks: + """A listing describes a link itself: a link to a folder (``/bin`` on most Linux systems) showed as a file.""" + + class _Server: + def __init__(self) -> None: + link = stat.S_IFLNK | 0o777 + self.listing = [ + SimpleNamespace(filename="bin", st_mode=link, st_size=7), + SimpleNamespace(filename="latest.log", st_mode=link, st_size=9), + SimpleNamespace(filename="gone", st_mode=link, st_size=4), + SimpleNamespace(filename="etc", st_mode=stat.S_IFDIR | 0o755, st_size=4096), + ] + self.targets = { + "/bin": SimpleNamespace(st_mode=stat.S_IFDIR | 0o755, st_size=4096), + "/latest.log": SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_size=1234), + } + self.stats: list[str] = [] + + def listdir_attr(self, _path: str): + return self.listing + + def stat(self, path: str): + self.stats.append(path) + if path not in self.targets: + raise FileNotFoundError(path) + return self.targets[path] + + def _listed(self, server) -> dict: + from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import SFTPClientWrapper + + wrapper = SFTPClientWrapper() + wrapper._ssh = SimpleNamespace(close=lambda: None) + wrapper._sftp = server + return {entry.filename: entry for entry in wrapper.list_dir("/")} + + def test_a_link_to_a_folder_is_a_folder(self, app): + entries = self._listed(self._Server()) + + assert stat.S_ISDIR(entries["bin"].st_mode) + + def test_a_link_to_a_file_has_the_files_size(self, app): + entries = self._listed(self._Server()) + + assert stat.S_ISREG(entries["latest.log"].st_mode) + assert entries["latest.log"].st_size == 1234 + + def test_a_link_leading_nowhere_stays_a_link(self, app): + entries = self._listed(self._Server()) + + assert stat.S_ISLNK(entries["gone"].st_mode) + + def test_only_links_are_looked_up(self, app): + server = self._Server() + + self._listed(server) + + assert sorted(server.stats) == ["/bin", "/gone", "/latest.log"] diff --git a/test/test_utils/test_sftp_remote_path.py b/test/test_utils/test_sftp_remote_path.py index ee34d681..7f319dad 100644 --- a/test/test_utils/test_sftp_remote_path.py +++ b/test/test_utils/test_sftp_remote_path.py @@ -5,15 +5,15 @@ import pytest -from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as sftp_mod -from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import ( - SSHFileTreeManager, +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session as sftp_mod +from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import ( SSH_KEEPALIVE_SECONDS, SFTPClientWrapper, - format_size, natural_key, remote_join, + sort_entries, ) +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import format_size class TestKeepalive: @@ -82,7 +82,7 @@ def test_directories_first_then_natural_order(self): _Entry("file2.txt", is_dir=False), _Entry("alpha_dir", is_dir=True), ] - ordered = [name for name, _ in SSHFileTreeManager._sort_entries(entries)] + ordered = [name for name, _ in sort_entries(entries)] # directories first (natural/case-insensitive), then files (natural) assert ordered == ["alpha_dir", "zeta_dir", "file2.txt", "file10.txt"] diff --git a/test/test_utils/test_sftp_session_sharing.py b/test/test_utils/test_sftp_session_sharing.py new file mode 100644 index 00000000..bcf29abe --- /dev/null +++ b/test/test_utils/test_sftp_session_sharing.py @@ -0,0 +1,135 @@ +"""One SFTP session, several users: one request at a time, and no file left cut short.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import threading + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session as viewer + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeSftp: + """Records overlapping requests; ``get`` writes *content* and can be held or made to fail.""" + + def __init__(self) -> None: + self.active = 0 + self.overlapped = False + self.hold = threading.Event() + self.hold.set() + self.inside = threading.Event() + self.fail_after: bytes | None = None + self.content = b"new contents" + self._count = threading.Lock() + + def _request(self) -> None: + with self._count: + self.active += 1 + self.overlapped = self.overlapped or self.active > 1 + self.inside.set() + self.hold.wait(WAIT_SECONDS) + with self._count: + self.active -= 1 + + def listdir_attr(self, _path): + self._request() + return [] + + def mkdir(self, _path) -> None: + self._request() + + def get(self, _remote, local, callback=None) -> None: + with open(local, "wb") as target: + if self.fail_after is not None: + target.write(self.fail_after) + raise OSError("the link went away") + self._request() + target.write(self.content) + + +def _wrapper(sftp: FakeSftp) -> viewer.SFTPClientWrapper: + wrapper = viewer.SFTPClientWrapper() + wrapper._ssh = object() + wrapper._sftp = sftp + return wrapper + + +class TestOneRequestAtATime: + def test_two_listings_take_turns(self, app): + sftp = FakeSftp() + sftp.hold.clear() + wrapper = _wrapper(sftp) + workers = [threading.Thread(target=wrapper.list_dir, args=(path,)) for path in ("/home", "/var")] + for worker in workers: + worker.start() + sftp.inside.wait(WAIT_SECONDS) + sftp.hold.set() + for worker in workers: + worker.join(WAIT_SECONDS) + + # Each expand used to run its own listing on the session at once; one + # thread could read the other's reply and leave it waiting forever. + assert not sftp.overlapped + + def test_a_menu_action_during_a_transfer_is_refused_not_frozen(self, app, tmp_path): + sftp = FakeSftp() + sftp.hold.clear() + wrapper = _wrapper(sftp) + download = threading.Thread( + target=wrapper.download, args=("/big.bin", str(tmp_path / "big.bin"))) + download.start() + sftp.inside.wait(WAIT_SECONDS) + + with pytest.raises(viewer.SftpBusy): + wrapper.mkdir("/new") + + sftp.hold.set() + download.join(WAIT_SECONDS) + assert not sftp.overlapped + + def test_a_menu_action_on_a_free_session_goes_through(self, app): + sftp = FakeSftp() + + _wrapper(sftp).mkdir("/new") + + assert sftp.active == 0 + + +class TestADownload: + def test_it_replaces_the_file_only_when_complete(self, app, tmp_path): + target = tmp_path / "report.txt" + target.write_bytes(b"the old report") + sftp = FakeSftp() + + _wrapper(sftp).download("/report.txt", str(target)) + + assert target.read_bytes() == b"new contents" + assert list(tmp_path.iterdir()) == [target] + + def test_one_that_fails_leaves_the_old_file_whole(self, app, tmp_path): + target = tmp_path / "report.txt" + target.write_bytes(b"the old report") + sftp = FakeSftp() + sftp.fail_after = b"half" + + with pytest.raises(OSError): + _wrapper(sftp).download("/report.txt", str(target)) + + # get() emptied the target before the first byte; a dropped link left + # it cut short. + assert target.read_bytes() == b"the old report" + assert list(tmp_path.iterdir()) == [target] # no partial file left behind diff --git a/test/test_utils/test_sftp_tree_actions.py b/test/test_utils/test_sftp_tree_actions.py new file mode 100644 index 00000000..db53eef7 --- /dev/null +++ b/test/test_utils/test_sftp_tree_actions.py @@ -0,0 +1,373 @@ +"""Create, rename and upload in the SFTP tree: names are one entry, and the tree follows.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import stat +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtCore import QThread +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import folder_item, plain_remote_name + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _entry(name: str, directory: bool = False): + mode = stat.S_IFDIR if directory else stat.S_IFREG + return SimpleNamespace(filename=name, st_mode=mode, st_size=10) + + +class FakeClient: + """A session over an in-memory listing that records what was asked of it.""" + + connected = True + + def __init__(self, listings: dict[str, list]) -> None: + self.listings = listings + self.made: list[str] = [] + self.renamed: list[tuple[str, str]] = [] + self.removed: list[str] = [] + self.threads: list = [] + + def list_dir(self, path: str): + return list(self.listings.get(path, [])) + + def mkdir(self, path: str) -> None: + self.threads.append(QThread.currentThread()) + self.made.append(path) + + def remove_file(self, path: str) -> None: + self.threads.append(QThread.currentThread()) + self.removed.append(path) + + remove_dir = remove_file + + def rename(self, old: str, new: str) -> None: + self.threads.append(QThread.currentThread()) + self.renamed.append((old, new)) + self.listings[new] = self.listings.pop(old, []) + + def close(self) -> None: + """Nothing to close.""" + + +@pytest.fixture() +def tree(app, monkeypatch): + client = FakeClient({ + "/": [_entry("src", directory=True), _entry("notes.txt")], + "/src": [_entry("main.py")], + }) + widget = tree_mod.SSHFileTreeManager() + widget.client = client + root = widget.make_item("/", "dir", 0, "/") + widget.tree.addTopLevelItem(root) + widget.add_placeholder(root) + widget.on_item_expanded(root) + _wait_for(lambda: _idle(widget)) + warnings: list = [] + monkeypatch.setattr(QMessageBox, "warning", lambda *args: warnings.append(args[2])) + yield widget, client, root, warnings + widget.close() + widget.deleteLater() + + +def _wait_for(condition) -> None: + deadline = time.monotonic() + WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + QApplication.processEvents() + time.sleep(0.01) + + +def _idle(widget) -> bool: + return not widget._listings and not widget._calls + + +def _child(item, name: str): + return next(item.child(i) for i in range(item.childCount()) if item.child(i).text(0) == name) + + +def _answer(widget, monkeypatch, text: str) -> None: + monkeypatch.setattr(widget, "get_text", lambda *_args: (text, True)) + + +class TestANameIsOneEntry: + @pytest.mark.parametrize("text", ["../x", "/tmp/x", "a/b", ".", "..", " ", "a\x00b"]) + def test_a_path_is_not_a_name(self, text): + assert plain_remote_name(text) is None + + def test_a_name_is_kept_stripped(self): + assert plain_remote_name(" new folder ") == "new folder" + + def test_a_folder_named_with_a_path_is_refused(self, tree, monkeypatch): + # "/tmp/x" made posixpath.join drop the folder it was created in + widget, client, root, warnings = tree + _answer(widget, monkeypatch, "/tmp/x") + + widget.action_create_folder(root) + + assert client.made == [] + assert warnings + + def test_a_rename_to_the_parent_is_refused(self, tree, monkeypatch): + widget, client, root, warnings = tree + _answer(widget, monkeypatch, "../x") + + widget.action_rename(_child(root, "notes.txt")) + + assert client.renamed == [] + assert warnings + + +class TestTheTreeFollows: + def test_a_renamed_folders_children_carry_the_new_path(self, tree, monkeypatch): + # They kept "/src/main.py", and a download or delete went there + widget, client, root, _warnings = tree + folder = _child(root, "src") + widget.on_item_expanded(folder) + _wait_for(lambda: _idle(widget)) + assert _child(folder, "main.py").text(3) == "/src/main.py" + _answer(widget, monkeypatch, "lib") + + widget.action_rename(folder) + _wait_for(lambda: _idle(widget)) + + assert folder.text(3) == "/lib" + assert _child(folder, "main.py").text(3) == "/lib/main.py" + + def test_a_folder_made_from_a_file_shows_in_that_files_folder(self, tree, monkeypatch): + # The refresh went to the file item, which has nothing to refresh + widget, client, root, _warnings = tree + _answer(widget, monkeypatch, "made") + + def made(path: str) -> None: + client.made.append(path) + client.listings["/"].append(_entry("made", directory=True)) + + client.mkdir = made + widget.action_create_folder(_child(root, "notes.txt")) + _wait_for(lambda: _idle(widget)) + + assert client.made == ["/made"] + assert _child(root, "made").text(3) == "/made" + + def test_a_file_items_folder_is_its_parent(self, tree): + widget, _client, root, _warnings = tree + + assert folder_item(_child(root, "notes.txt")) is root + assert folder_item(root) is root + assert folder_item(None) is None + + +class TestTheMenuDoesNotWaitOnTheServer: + """Create, rename and delete run off the UI thread: an SFTP reply has no timeout.""" + + def test_each_request_runs_on_a_worker(self, app, tree, monkeypatch): + widget, client, root, _warnings = tree + _answer(widget, monkeypatch, "new") + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + widget.action_create_folder(root) + _wait_for(lambda: _idle(widget)) + _answer(widget, monkeypatch, "renamed.txt") + widget.action_rename(_child(root, "notes.txt")) + _wait_for(lambda: _idle(widget)) + widget.action_delete(_child(root, "renamed.txt")) + _wait_for(lambda: _idle(widget)) + + assert client.made == ["/new"] + assert client.renamed == [("/notes.txt", "/renamed.txt")] + assert client.removed == ["/renamed.txt"] + assert len(client.threads) == 3 + assert all(thread is not app.thread() for thread in client.threads) + assert "renamed.txt" not in [root.child(i).text(0) for i in range(root.childCount())] + + def test_a_failed_request_is_shown_and_changes_nothing(self, app, tree, monkeypatch): + widget, client, root, _warnings = tree + shown: list = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: shown.append(args[2])) + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + def refuse(_path: str) -> None: + raise OSError("Permission denied") + + client.remove_file = refuse + widget.action_delete(_child(root, "notes.txt")) + _wait_for(lambda: _idle(widget)) + + assert any("Permission denied" in text for text in shown) + assert _child(root, "notes.txt").text(3) == "/notes.txt" + + +class TestTheNameADownloadSuggests: + """A server's name may hold backslashes, and one climbing into Startup was offered as the save name.""" + + @pytest.mark.parametrize(("remote", "suggested"), [ + ("/home/u/..\\..\\AppData\\Startup\\u.bat", "u.bat"), + ("/srv/a:b?.txt", "a_b_.txt"), + ("/data/report.csv", "report.csv"), + ("/x/..", "download"), + ("/x/ . ", "download"), + ]) + def test_only_the_last_safe_part(self, remote, suggested): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import local_file_name + + assert local_file_name(remote) == suggested + + + +class TestAnEntryNamedDots: + """The row a folder shows until expanded read "...", and was told apart by that text alone.""" + + def test_is_not_taken_for_the_placeholder(self, tree): + widget, client, root, _warnings = tree + client.listings["/"].append(_entry("odd", directory=True)) + client.listings["/odd"] = [_entry("...", directory=True), _entry("b.txt")] + widget.action_refresh(root) + _wait_for(lambda: _idle(widget)) + folder = _child(root, "odd") + widget.on_item_expanded(folder) + _wait_for(lambda: _idle(widget)) + + assert not widget.is_placeholder_present(folder) + widget.on_item_expanded(folder) # collapsed and expanded again + _wait_for(lambda: _idle(widget)) + + assert [folder.child(i).text(0) for i in range(folder.childCount())] == ["...", "b.txt"] + assert _child(folder, "...").text(3) == "/odd/..." + + def test_a_folder_not_yet_expanded_still_has_its_placeholder(self, tree): + _widget, _client, root, _warnings = tree + folder = _child(root, "src") + + assert tree[0].is_placeholder_present(folder) + + +class TestTheMenu: + def _open(self, widget, monkeypatch, item, choose: int | None = None) -> list[str]: + shown: list[str] = [] + + class AnsweringMenu(tree_mod.QMenu): + """Answers at once. PySide6 does not look up ``exec`` patched onto QMenu itself.""" + + def exec(self, _pos): + shown.extend(action.text() for action in self.actions()) + return None if choose is None else self.actions()[choose] + + monkeypatch.setattr(tree_mod, "QMenu", AnsweringMenu) + monkeypatch.setattr(widget.tree, "itemAt", lambda _pos: item) + widget.on_context_menu(widget.tree.rect().center()) + return shown + + def test_speaks_the_ide_language(self, app, monkeypatch): + # The menu and the column headers were English whatever the IDE spoke + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as word, + ) + monkeypatch.setattr(tree_mod.language_wrapper, "language_word_dict", word) + widget = tree_mod.SSHFileTreeManager() + try: + shown = self._open(widget, monkeypatch, None) + headers = widget.tree.headerItem() + + assert shown == ["重新整理", "建立資料夾", "重新命名", "刪除", "下載", "上傳至此資料夾"] + assert [headers.text(i) for i in range(4)] == ["名稱", "類型", "大小", "路徑"] + # The Type column read "dir" / "file" + folder, file = widget.make_item("a", "dir", 0, "/a"), widget.make_item("b", "file", 5, "/b") + assert (folder.text(1), file.text(1)) == ("資料夾", "檔案") + assert tree_mod.is_folder(folder) and tree_mod.is_file(file) + finally: + widget.close() + widget.deleteLater() + + def test_on_the_placeholder_acts_on_its_folder(self, tree, monkeypatch): + # It has no path: Delete asked about '' and Rename sent ("", "/name") + widget, _client, root, _warnings = tree + folder = _child(root, "src") + refreshed: list = [] + monkeypatch.setattr(widget, "action_refresh", refreshed.append) + + self._open(widget, monkeypatch, folder.child(0), choose=0) + + assert refreshed == [folder] + + +class TestRenaming: + def test_starts_from_the_current_name(self, tree, monkeypatch): + # The box was empty: changing one letter meant typing the whole name + widget, _client, root, _warnings = tree + asked: list = [] + monkeypatch.setattr(widget, "get_text", lambda *args: asked.append(args) or ("", False)) + + widget.action_rename(_child(root, "notes.txt")) + + assert asked[0][2] == "notes.txt" + + def test_the_same_name_sends_nothing(self, tree, monkeypatch): + widget, client, root, _warnings = tree + _answer(widget, monkeypatch, " notes.txt ") + + widget.action_rename(_child(root, "notes.txt")) + + assert client.renamed == [] + assert not widget._calls + + def test_the_dialog_is_given_the_name(self, tree, monkeypatch): + widget, _client, _root, _warnings = tree + given: list = [] + monkeypatch.setattr(tree_mod.QInputDialog, "getText", + staticmethod(lambda *args: given.append(args) or ("x", True))) + + assert widget.get_text("t", "l", "start") == ("x", True) + assert given[0][-1] == "start" + + +class TestDeletingAFolder: + def test_a_refusal_says_only_an_empty_folder_goes(self, app, tree, monkeypatch): + # OpenSSH refuses a folder with something in it with a bare "Failure" + widget, client, root, _warnings = tree + shown: list = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: shown.append(args[2])) + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + def refuse(_path: str) -> None: + raise OSError("Failure") + + client.remove_dir = refuse + widget.action_delete(_child(root, "src")) + _wait_for(lambda: _idle(widget)) + + word = tree_mod.language_wrapper.language_word_dict + expected = word.get("ssh_file_viewer_message_folder_not_removed").format(error="Failure") + assert len(shown) == 1 and expected in shown[0] + assert _child(root, "src").text(3) == "/src" + + def test_a_file_refusal_is_shown_as_it_is(self, app, tree, monkeypatch): + widget, client, root, _warnings = tree + shown: list = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: shown.append(args[2])) + monkeypatch.setattr(QMessageBox, "question", lambda *_args: QMessageBox.StandardButton.Yes) + + def refuse(_path: str) -> None: + raise OSError("Permission denied") + + client.remove_file = refuse + widget.action_delete(_child(root, "notes.txt")) + _wait_for(lambda: _idle(widget)) + + assert len(shown) == 1 and "empty" not in shown[0] and "Permission denied" in shown[0] diff --git a/test/test_utils/test_sftp_upload.py b/test/test_utils/test_sftp_upload.py new file mode 100644 index 00000000..20583165 --- /dev/null +++ b/test/test_utils/test_sftp_upload.py @@ -0,0 +1,183 @@ +"""An upload asks before replacing a file, and never leaves the server's copy cut short.""" +from __future__ import annotations + +import errno +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import time +from types import SimpleNamespace + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeSftp: + """A server's files in a dict; ``put`` can fail halfway, ``posix_rename`` can be missing.""" + + def __init__(self, files: dict[str, bytes] | None = None) -> None: + self.files = dict(files or {}) + self.fail_put = False + self.has_posix_rename = True + self.put_to: list[str] = [] + + def stat(self, path: str): + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + return object() + + def put(self, local: str, remote: str, callback=None) -> None: + self.put_to.append(remote) + with open(local, "rb") as source: + data = source.read() + if self.fail_put: + self.files[remote] = data[:2] + raise OSError("the link went away") + self.files[remote] = data + + def posix_rename(self, old: str, new: str) -> None: + if not self.has_posix_rename: + raise OSError("Operation unsupported") + self.files[new] = self.files.pop(old) + + def rename(self, old: str, new: str) -> None: + if new in self.files: + raise OSError("Failure") + self.files[new] = self.files.pop(old) + + def close(self) -> None: + """Nothing to close.""" + + def remove(self, path: str) -> None: + if path not in self.files: + raise OSError(errno.ENOENT, "No such file") + del self.files[path] + + +def _wrapper(sftp: FakeSftp) -> sftp_session.SFTPClientWrapper: + wrapper = sftp_session.SFTPClientWrapper() + wrapper._ssh = SimpleNamespace(close=lambda: None) + wrapper._sftp = sftp + return wrapper + + +@pytest.fixture() +def local_file(tmp_path): + path = tmp_path / "config.yaml" + path.write_bytes(b"new config") + return str(path) + + +class TestTheWrapper: + def test_a_new_file_arrives_whole_under_its_name(self, app, local_file): + sftp = FakeSftp() + + assert _wrapper(sftp).upload(local_file, "/etc/app/config.yaml") is True + + assert sftp.files == {"/etc/app/config.yaml": b"new config"} + # It went through a temporary name beside the target + assert sftp.put_to[0].startswith("/etc/app/.config.yaml.") + + def test_an_existing_file_is_not_touched_unless_replacing(self, app, local_file): + # It used to be replaced without a word + sftp = FakeSftp({"/etc/app/config.yaml": b"old config"}) + + assert _wrapper(sftp).upload(local_file, "/etc/app/config.yaml") is False + + assert sftp.files == {"/etc/app/config.yaml": b"old config"} + assert sftp.put_to == [] + + def test_replacing_puts_the_new_file_in_its_place(self, app, local_file): + sftp = FakeSftp({"/etc/app/config.yaml": b"old config"}) + + assert _wrapper(sftp).upload(local_file, "/etc/app/config.yaml", replace=True) is True + + assert sftp.files == {"/etc/app/config.yaml": b"new config"} + + def test_a_broken_upload_leaves_the_old_file_whole_and_no_leftover(self, app, local_file): + # put() empties its target first: a dropped link left it cut short + sftp = FakeSftp({"/etc/app/config.yaml": b"old config"}) + sftp.fail_put = True + + with pytest.raises(OSError): + _wrapper(sftp).upload(local_file, "/etc/app/config.yaml", replace=True) + + assert sftp.files == {"/etc/app/config.yaml": b"old config"} + + def test_a_server_without_posix_rename_still_gets_the_file(self, app, local_file): + sftp = FakeSftp({"/config.yaml": b"old config"}) + sftp.has_posix_rename = False + + assert _wrapper(sftp).upload(local_file, "/config.yaml", replace=True) is True + + assert sftp.files == {"/config.yaml": b"new config"} + + +class TestTheTree: + def _upload(self, app, monkeypatch, sftp: FakeSftp, local_file: str, answer) -> list: + widget = viewer.SSHFileTreeManager() + widget.client = _wrapper(sftp) + asked: list = [] + + def question(*args, **_kwargs): + asked.append(args[2]) + return answer + + monkeypatch.setattr(QMessageBox, "question", question) + monkeypatch.setattr(QMessageBox, "information", lambda *_args: None) + widget._start_transfer(downloading=False, remote_path="/config.yaml", local_path=local_file, + title="Uploaded", message="Uploaded to") + deadline = time.monotonic() + WAIT_SECONDS + + def transferring() -> bool: + # A finished transfer is let go of (it is then None) + return widget._transfer is not None and widget._transfer.isRunning() + + while transferring() or (asked and answer == QMessageBox.StandardButton.Yes + and sftp.files.get("/config.yaml") != b"new config"): + assert time.monotonic() < deadline, "timed out" + app.processEvents() + time.sleep(0.01) + app.processEvents() + if widget._transfer is not None: + widget._transfer.wait(1000) + widget.close() + return asked + + def test_the_user_is_asked_and_a_no_keeps_the_file(self, app, monkeypatch, local_file): + sftp = FakeSftp({"/config.yaml": b"old config"}) + + asked = self._upload(app, monkeypatch, sftp, local_file, QMessageBox.StandardButton.No) + + assert len(asked) == 1 and "/config.yaml" in asked[0] + assert sftp.files == {"/config.yaml": b"old config"} + + def test_a_yes_replaces_it(self, app, monkeypatch, local_file): + sftp = FakeSftp({"/config.yaml": b"old config"}) + + asked = self._upload(app, monkeypatch, sftp, local_file, QMessageBox.StandardButton.Yes) + + assert len(asked) == 1 + assert sftp.files == {"/config.yaml": b"new config"} + + def test_a_new_file_is_not_asked_about(self, app, monkeypatch, local_file): + sftp = FakeSftp() + + asked = self._upload(app, monkeypatch, sftp, local_file, QMessageBox.StandardButton.No) + + assert asked == [] + assert sftp.files == {"/config.yaml": b"new config"} diff --git a/test/test_utils/test_skills_panel.py b/test/test_utils/test_skills_panel.py new file mode 100644 index 00000000..e9b28f16 --- /dev/null +++ b/test/test_utils/test_skills_panel.py @@ -0,0 +1,120 @@ +"""The Skills panel keeps what the user put in the prompt, and sends only a prompt with code in it. + +Switching the template replaced the edit area without a word, taking the code +the user had pasted with it; and a template sent as it was asked the endpoint +to review ``{code_diff}``. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.extend_ai_gui import prompt_store +from pybreeze.pybreeze_ui.extend_ai_gui.ai_gui_global_variable import SKILLS_TEMPLATE_FILES +from pybreeze.pybreeze_ui.extend_ai_gui.skills import skills_send_gui +from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import SkillsSendGUI + +_PASTED = "please look at this:\ndef f():\n return 1\n" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def panel(tmp_path, monkeypatch): + # The built-in prompts, never the user's own + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) + widget = SkillsSendGUI() + yield widget + widget.deleteLater() + + +@pytest.fixture() +def answer(monkeypatch): + """What the replace question gets, and how often it was asked.""" + state = {"reply": QMessageBox.StandardButton.No, "asked": 0} + + def question(*_args): + state["asked"] += 1 + return state["reply"] + + monkeypatch.setattr(QMessageBox, "question", staticmethod(question)) + return state + + +def _type(panel, text: str) -> None: + """Put *text* in the prompt as typing or pasting does, marking it edited.""" + panel.prompt_input.selectAll() + panel.prompt_input.insertPlainText(text) + + +class TestSwitchingTheTemplate: + def test_an_untouched_prompt_is_replaced_without_asking(self, panel, answer): + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[1]) + + assert answer["asked"] == 0 + assert panel.prompt_input.toPlainText() != "" + + def test_an_edited_prompt_is_kept_when_the_user_says_no(self, panel, answer): + _type(panel, _PASTED) + + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[1]) + + assert answer["asked"] == 1 + assert panel.prompt_input.toPlainText() == _PASTED + # The selector names what is in the edit area + assert panel.prompt_select.currentText() == SKILLS_TEMPLATE_FILES[0] + + def test_an_edited_prompt_is_replaced_when_the_user_says_yes(self, panel, answer): + _type(panel, _PASTED) + answer["reply"] = QMessageBox.StandardButton.Yes + + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[1]) + + assert panel.prompt_input.toPlainText() != _PASTED + # A fresh template is not an edit: the next switch does not ask + panel.prompt_select.setCurrentText(SKILLS_TEMPLATE_FILES[0]) + assert answer["asked"] == 1 + + +class TestSending: + def test_a_template_with_no_code_in_it_is_not_sent(self, panel, monkeypatch): + started: list = [] + monkeypatch.setattr(skills_send_gui, "RequestThread", lambda *args: started.append(args)) + panel.api_url_input.setText("https://skills.example/api") + + panel.send_prompt() # the template as loaded, {code_diff} and all + + assert started == [] + assert "{code_diff}" in panel.response_output.toPlainText() + + def test_a_prompt_with_the_code_in_place_is_sent(self, panel, monkeypatch): + sent: list = [] + + class Thread: + def __init__(self, url, text) -> None: + sent.append(text) + self.answered = self.error = self.finished = self + + def connect(self, _slot) -> None: + """Nothing to connect to.""" + + def start(self) -> None: + """Nothing to run.""" + + monkeypatch.setattr(skills_send_gui, "RequestThread", Thread) + panel.api_url_input.setText("https://skills.example/api") + _type(panel, panel.prompt_input.toPlainText().replace("{code_diff}", _PASTED)) + + panel.send_prompt() + + assert len(sent) == 1 and _PASTED.strip() in sent[0] diff --git a/test/test_utils/test_skills_request.py b/test/test_utils/test_skills_request.py new file mode 100644 index 00000000..303bd5ff --- /dev/null +++ b/test/test_utils/test_skills_request.py @@ -0,0 +1,129 @@ +"""What a Skills request reports for each kind of answer the endpoint gives.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +import requests + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.extend_ai_gui.skills import skills_send_gui +from pybreeze.pybreeze_ui.extend_ai_gui.skills.skills_send_gui import RequestThread + +_URL = "https://skills.example/api" + + +@pytest.fixture(scope="module", autouse=True) +def app(): + from PySide6.QtWidgets import QApplication + + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeResponse: + def __init__(self, status_code: int, body: str = "", location: str | None = None) -> None: + self.status_code = status_code + self.ok = status_code < 400 + self.is_redirect = location is not None + self.headers = {"Location": location} if location else {} + self.encoding = "utf-8" + self._body = body.encode("utf-8") + + def iter_content(self, chunk_size: int = 65536): + yield self._body + + def close(self) -> None: + """Nothing to close.""" + + +class FakeSession: + """The session a request is sent through, answering every post with *post*.""" + + def __init__(self, post) -> None: + self.post = post + + def __enter__(self): + return self + + def __exit__(self, *_exc) -> None: + """Nothing to close.""" + + +def _run(monkeypatch, answer) -> tuple[list[str], list[str]]: + """Run one request against *answer* (a response, or an exception to raise).""" + monkeypatch.setattr(skills_send_gui, "validate_url", lambda url: url) + + def post(*_args, **_kwargs): + if isinstance(answer, Exception): + raise answer + return answer + + monkeypatch.setattr(skills_send_gui, "public_session", lambda: FakeSession(post)) + answered: list[str] = [] + errors: list[str] = [] + thread = RequestThread(_URL, "print(1)") + thread.answered.connect(answered.append) + thread.error.connect(errors.append) + thread.run() # on this thread: direct connections, no event loop needed + return answered, errors + + +class TestWhatARequestReports: + def test_a_good_answer_is_shown_as_is(self, monkeypatch): + assert _run(monkeypatch, FakeResponse(200, "looks fine")) == (["looks fine"], []) + + def test_a_redirect_is_shown_with_where_it_went(self, monkeypatch): + answered, errors = _run(monkeypatch, FakeResponse(302, location="https://elsewhere.example")) + + assert errors == [] + assert "302" in answered[0] and "Redirect (not followed) to https://elsewhere.example" in answered[0] + + def test_a_redirect_shows_only_the_host_it_names(self, monkeypatch): + # A trailing-slash redirect repeats the query, and the token in it was shown + answered, _errors = _run(monkeypatch, FakeResponse( + 301, location="https://user:pw@api.example:8443/x/?key=SECRET")) + + assert "https://api.example:8443" in answered[0] + assert "SECRET" not in answered[0] and "pw" not in answered[0] and "/x/" not in answered[0] + + @pytest.mark.parametrize("status", [401, 403]) + def test_a_refusal_is_an_error(self, monkeypatch, status): + answered, errors = _run(monkeypatch, FakeResponse(status, "no")) + + assert answered == [] + assert str(status) in errors[0] and "Authentication/Authorization failed" in errors[0] + + def test_a_server_error_is_an_error_with_its_body(self, monkeypatch): + answered, errors = _run(monkeypatch, FakeResponse(503, "overloaded")) + + assert answered == [] + assert "503" in errors[0] and "Server error: overloaded" in errors[0] + + def test_another_client_error_is_shown_with_its_body(self, monkeypatch): + answered, errors = _run(monkeypatch, FakeResponse(422, "bad field")) + + assert errors == [] + assert "422" in answered[0] and "bad field" in answered[0] + + def test_a_failed_request_is_an_error(self, monkeypatch): + answered, errors = _run(monkeypatch, requests.ConnectionError("refused")) + + assert answered == [] + assert "could not connect to the server" in errors[0] + + +def test_a_failed_request_does_not_log_the_url(monkeypatch): + # A requests error names the whole URL, and an API URL may carry a token. + logged: list = [] + monkeypatch.setattr( + skills_send_gui.pybreeze_logger, "error", + lambda message, *args: logged.append(message % args)) + + _run(monkeypatch, requests.ConnectionError( + "Max retries exceeded with url: /v1?token=not-a-real-token")) + + assert logged and all("not-a-real-token" not in line for line in logged) diff --git a/test/test_utils/test_ssh_host_key_policy.py b/test/test_utils/test_ssh_host_key_policy.py new file mode 100644 index 00000000..2960d2c6 --- /dev/null +++ b/test/test_utils/test_ssh_host_key_policy.py @@ -0,0 +1,225 @@ +"""An unknown SSH host key: asked about once per Connect, and never written away.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import threading + +import paramiko +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_host_key_policy as policy_mod + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture(scope="module") +def keys(): + return paramiko.RSAKey.generate(1024), paramiko.RSAKey.generate(1024) + + +@pytest.fixture() +def asked(app, tmp_path, monkeypatch): + """Answers every question with ``asked["answer"]`` and counts them.""" + state = {"answer": True, "count": 0} + + class Asker: + def ask(self, _parent, _title, _message) -> bool: + state["count"] += 1 + return state["answer"] + + monkeypatch.setattr(policy_mod, "pybreeze_data_dir", lambda: tmp_path) + monkeypatch.setattr(policy_mod, "host_key_asker", Asker) + monkeypatch.setattr(policy_mod, "_RECENT_DECLINES", {}) + return state + + +def _meet(hostname: str, key) -> paramiko.SSHClient: + """What a connect does on meeting *key*: the client read the file at its Connect.""" + client = paramiko.SSHClient() + policy_mod.InteractiveHostKeyPolicy().missing_host_key(client, hostname, key) + return client + + +def test_both_halves_of_one_connect_ask_once(asked, keys): + # The shell and the file tree each met the unknown key; there used to be + # two identical questions, one on top of the other. + _meet("host.example", keys[0]) + second = _meet("host.example", keys[0]) + + assert asked["count"] == 1 + assert second.get_host_keys().lookup("host.example") + + +def test_a_no_is_not_asked_again_by_the_other_half(asked, keys): + asked["answer"] = False + + for _ in range(2): + with pytest.raises(paramiko.SSHException): + _meet("host.example", keys[0]) + + assert asked["count"] == 1 + + +def test_two_tabs_accepting_two_hosts_keep_both(asked, keys): + # Each client saved its own copy of the file, read at its Connect, so the + # last one to save wrote the other host away. + first, second = paramiko.SSHClient(), paramiko.SSHClient() + policy = policy_mod.InteractiveHostKeyPolicy() + policy.missing_host_key(first, "one.example", keys[0]) + policy.missing_host_key(second, "two.example", keys[1]) + + known = paramiko.HostKeys(str(policy_mod._known_hosts_path())) + assert known.lookup("one.example") and known.lookup("two.example") + + +def test_another_key_for_a_known_host_is_still_asked_about(asked, keys): + _meet("host.example", keys[0]) + + _meet("host.example", keys[1]) + + assert asked["count"] == 2 + + +def test_the_questions_come_one_at_a_time(asked, keys, monkeypatch): + inside = threading.Event() + release = threading.Event() + overlapping: list = [] + + class SlowAsker: + def ask(self, *_args) -> bool: + overlapping.append(inside.is_set()) + inside.set() + release.wait(5) + inside.clear() + return True + + monkeypatch.setattr(policy_mod, "host_key_asker", SlowAsker) + workers = [threading.Thread(target=_meet, args=(name, keys[0])) + for name in ("one.example", "two.example")] + for worker in workers: + worker.start() + inside.wait(5) + release.set() + for worker in workers: + worker.join(5) + + assert overlapping == [False, False] + + +class TestAQuestionNobodyCanAnswer: + """A closed panel's question is a No, never the previous question's answer.""" + + def _closed_panel(self): + import shiboken6 + from PySide6.QtWidgets import QWidget + + panel = QWidget() + shiboken6.delete(panel) + return panel + + def test_on_the_ui_thread(self, app): + # The previous Yes used to come back, and the key was stored for good + asker = policy_mod.HostKeyAsker() + asker._answer = True + + assert asker.ask(self._closed_panel(), "title", "message") is False + + def test_from_the_connecting_thread(self, app): + import time + + asker = policy_mod.HostKeyAsker() + asker._answer = True + panel = self._closed_panel() + answers: list = [] + worker = threading.Thread(target=lambda: answers.append(asker.ask(panel, "title", "message"))) + worker.start() + deadline = time.monotonic() + 10 + while worker.is_alive() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.01) + worker.join(1) + + assert answers == [False] + + +def test_a_store_that_fails_keeps_the_hosts_already_trusted(asked, keys, tmp_path, monkeypatch): + # HostKeys.save emptied the file first: a failure part-way lost every host + from pybreeze.utils.file_process import replace_file + + _meet("first.example", keys[0]) + before = (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8") + + def refuse(*_args): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(replace_file.os, "replace", refuse) + _meet("second.example", keys[1]) + + assert (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8") == before + assert sorted(path.name for path in tmp_path.iterdir()) == ["ssh_known_hosts"] + + +# A line whose key is not base64: HostKeys.load raised InvalidHostKey, no SSHException +_BAD_LINE = "badhost ssh-ed25519 abc" +# A line paramiko 4 no longer reads (it dropped DSA), which is still someone's host +_DSA_LINE = "old.example ssh-dss AAAAB3NzaC1kc3MAAACBAP==" + + +def test_a_bad_line_skips_that_line_and_the_rest_still_load(asked, keys, tmp_path): + good = paramiko.hostkeys.HostKeyEntry(["good.example"], keys[0]).to_line().strip() + (tmp_path / "ssh_known_hosts").write_text(f"{_BAD_LINE}\n{good}\n", encoding="utf-8") + client = paramiko.SSHClient() + + policy_mod.apply_host_key_policy(client, None) + + assert client.get_host_keys().lookup("good.example")["ssh-rsa"] == keys[0] + assert client.get_host_keys().lookup("badhost") is None + + +def test_accepting_a_host_keeps_the_lines_paramiko_could_not_read(asked, keys, tmp_path): + (tmp_path / "ssh_known_hosts").write_text(f"{_BAD_LINE}\n{_DSA_LINE}\n", encoding="utf-8") + + _meet("new.example", keys[0]) + + lines = (tmp_path / "ssh_known_hosts").read_text(encoding="utf-8").splitlines() + assert lines[:2] == [_BAD_LINE, _DSA_LINE] + assert policy_mod._read_known_hosts().lookup("new.example")["ssh-rsa"] == keys[0] + + +def test_accepting_a_host_keeps_bytes_that_are_not_utf8(asked, keys, tmp_path): + # The file was decoded with replacement and written back: \xe9 became U+FFFD + (tmp_path / "ssh_known_hosts").write_bytes(b"# caf\xe9\n") + + _meet("new.example", keys[0]) + + assert (tmp_path / "ssh_known_hosts").read_bytes().startswith(b"# caf\xe9\n") + + +def test_a_file_that_cannot_be_read_is_not_written_over(asked, keys, tmp_path, monkeypatch): + # Read as empty, it was replaced by the one new line: every host trusted so far gone + from pathlib import Path + + known = tmp_path / "ssh_known_hosts" + known.write_bytes(b"trusted.example ssh-ed25519 AAAA\n") + real_read = Path.read_bytes + + def locked(self): + if self == known: + raise PermissionError(13, "locked") + return real_read(self) + + monkeypatch.setattr(Path, "read_bytes", locked) + _meet("new.example", keys[0]) + monkeypatch.setattr(Path, "read_bytes", real_read) + + assert known.read_bytes() == b"trusted.example ssh-ed25519 AAAA\n" diff --git a/test/test_utils/test_ssh_login_form.py b/test/test_utils/test_ssh_login_form.py new file mode 100644 index 00000000..8fb03273 --- /dev/null +++ b/test/test_utils/test_ssh_login_form.py @@ -0,0 +1,73 @@ +"""The SSH login form: Enter connects, and the key file can be picked instead of typed.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_login_widget as login_mod + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def form(app): + widget = login_mod.LoginWidget() + yield widget + widget.deleteLater() + + +class TestEnter: + @pytest.mark.parametrize("field", ["host_edit", "user_edit", "pass_edit", "key_edit"]) + def test_in_any_field_connects(self, form, field): + # Connecting needed the mouse + clicks: list = [] + form.connect_btn.clicked.connect(lambda: clicks.append(True)) + + getattr(form, field).returnPressed.emit() + + assert clicks == [True] + + +class TestChoosingTheKey: + def test_a_chosen_file_fills_the_path_and_ticks_key_authentication(self, form, monkeypatch, tmp_path): + key = tmp_path / "id_ed25519" + asked: list = [] + monkeypatch.setattr(login_mod.QFileDialog, "getOpenFileName", + staticmethod(lambda *args: asked.append(args) or (str(key), ""))) + + form.browse_key_btn.click() + + assert form.key_edit.text() == str(key) + assert form.use_key_check.isChecked() + assert asked[0][1] == login_mod.language_wrapper.language_word_dict.get( + "ssh_login_widget_dialog_title_choose_key") + + def test_starts_in_the_ssh_folder(self, form, monkeypatch, tmp_path): + (tmp_path / ".ssh").mkdir() + monkeypatch.setattr(login_mod.Path, "home", staticmethod(lambda: tmp_path)) + asked: list = [] + monkeypatch.setattr(login_mod.QFileDialog, "getOpenFileName", + staticmethod(lambda *args: asked.append(args) or ("", ""))) + + form.choose_key_file() + + assert asked[0][2] == str(tmp_path / ".ssh") + + def test_cancelling_changes_nothing(self, form, monkeypatch): + form.key_edit.setText("typed") + monkeypatch.setattr(login_mod.QFileDialog, "getOpenFileName", staticmethod(lambda *args: ("", ""))) + + form.choose_key_file() + + assert form.key_edit.text() == "typed" + assert not form.use_key_check.isChecked() diff --git a/test/test_utils/test_ssh_reentrancy.py b/test/test_utils/test_ssh_reentrancy.py index e8d5bf08..94ab57aa 100644 --- a/test/test_utils/test_ssh_reentrancy.py +++ b/test/test_utils/test_ssh_reentrancy.py @@ -1,40 +1,408 @@ -"""Re-clicking Connect must tear down the prior SSH session, not leak it.""" +"""Connecting an SSH tab: off the UI thread, and never leaking the prior session.""" from __future__ import annotations import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from unittest.mock import MagicMock +import threading +import time -from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget as mod +import pytest +from PySide6.QtCore import QThread +from PySide6.QtWidgets import QApplication +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget as shell_mod +from pybreeze.pybreeze_ui.connect_gui.ssh import sftp_session +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as tree_mod +from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_host_key_policy as host_key_mod +from pybreeze.pybreeze_ui.thread_keeper import is_kept -def _widget_with_valid_inputs(): - widget = mod.SSHCommandWidget.__new__(mod.SSHCommandWidget) - widget.word_dict = {} - login = MagicMock() - login.host_edit.text.return_value = "host" - login.user_edit.text.return_value = "user" - login.port_spin.value.return_value = 22 - login.use_key_check.isChecked.return_value = False - login.key_edit.text.return_value = "" - login.pass_edit.text.return_value = "pw" - widget.login_widget = login +WAIT_SECONDS = 5 + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +def _wait_for(condition) -> None: + deadline = time.monotonic() + WAIT_SECONDS + while not condition(): + assert time.monotonic() < deadline, "timed out" + QApplication.processEvents() + time.sleep(0.01) + + +class FakeClient: + """A paramiko client whose connect waits for *release*, then succeeds or raises *error*.""" + + def __init__(self, release: threading.Event | None = None, error: Exception | None = None) -> None: + self.release = release + self.error = error + self.closed = False + self.connect_thread: QThread | None = None + self.connect_options: dict = {} + + def connect(self, **options) -> None: + self.connect_thread = QThread.currentThread() + self.connect_options = options + if self.release is not None: + self.release.wait(WAIT_SECONDS) + if self.error is not None: + raise self.error + + def close(self) -> None: + self.closed = True + + @staticmethod + def get_transport(): + return None + + def invoke_shell(self, **_options): + self.shell_thread = QThread.currentThread() + return FakeShellChannel() + + +class FakeShellChannel: + closed = False + + def settimeout(self, _timeout) -> None: + """Non-blocking is what the reader expects.""" + + +def _shell(monkeypatch, client: FakeClient): + widget = shell_mod.SSHCommandWidget() + widget.login_widget.host_edit.setText("host") + widget.login_widget.user_edit.setText("user") + widget.login_widget.pass_edit.setText("pw") + monkeypatch.setattr(shell_mod.paramiko, "SSHClient", lambda: client) + monkeypatch.setattr(shell_mod, "apply_host_key_policy", lambda _client, _parent: None) return widget -class TestConnectReentrancy: - def test_cleanup_runs_before_new_session(self, monkeypatch): - widget = _widget_with_valid_inputs() +class TestShellConnect: + def test_cleanup_runs_before_the_new_session(self, app, monkeypatch): + widget = _shell(monkeypatch, FakeClient()) order = [] widget._cleanup = lambda: order.append("cleanup") widget._start_shell = lambda *args: order.append("start_shell") - monkeypatch.setattr(mod.paramiko, "SSHClient", lambda: MagicMock()) - monkeypatch.setattr(mod, "apply_host_key_policy", lambda client, parent: None) widget.connect_ssh() + _wait_for(lambda: "start_shell" in order) # The prior session is torn down before a new client/shell is created, # so re-connecting cannot leak the old client or orphan its reader. assert order == ["cleanup", "start_shell"] + + def test_the_connect_does_not_hold_the_ui_thread(self, app, monkeypatch): + release = threading.Event() + client = FakeClient(release) + widget = _shell(monkeypatch, client) + started = [] + widget._start_shell = lambda *args: started.append(QThread.currentThread()) + states = [] + widget.state_changed.connect(lambda: states.append("changed")) + + widget.connect_ssh() + assert not started # returned while the host is still answering + release.set() + _wait_for(lambda: started and states) + + assert client.connect_thread is not app.thread() + # The channel is opened where the connect ran: it waits on the server + # (up to an hour for the session) and used to hold the UI thread + assert client.shell_thread is client.connect_thread + assert started == [app.thread()] # only its reader is started on the UI thread + + def test_a_shell_that_will_not_open_is_reported_like_a_failed_connect(self, app, monkeypatch): + client = FakeClient() + + def refuse(**_options): + raise shell_mod.paramiko.SSHException("administratively prohibited") + + client.invoke_shell = refuse + widget = _shell(monkeypatch, client) + states = [] + widget.state_changed.connect(lambda: states.append("changed")) + + widget.connect_ssh() + _wait_for(lambda: states) + + assert "administratively prohibited" in widget.terminal.toPlainText() + assert client.closed + assert widget.shell_channel is None + + def test_a_second_click_while_connecting_is_ignored(self, app, monkeypatch): + release = threading.Event() + widget = _shell(monkeypatch, FakeClient(release)) + started = [] + widget._start_shell = lambda *args: started.append(True) + + widget.connect_ssh() + first = widget._connecting + widget.connect_ssh() + release.set() + _wait_for(lambda: started) + + assert widget._connecting is first + assert started == [True] + + def test_a_failed_connect_is_reported_and_dropped(self, app, monkeypatch): + client = FakeClient(error=OSError("unreachable")) + widget = _shell(monkeypatch, client) + states = [] + widget.state_changed.connect(lambda: states.append("changed")) + + widget.connect_ssh() + _wait_for(lambda: states) + + assert "unreachable" in widget.terminal.toPlainText() + assert client.closed + assert widget.ssh_client is None + + def test_closing_while_connecting_closes_the_late_session(self, app, monkeypatch): + release = threading.Event() + client = FakeClient(release) + widget = _shell(monkeypatch, client) + widget._start_shell = lambda *args: pytest.fail("shell opened after close") + + widget.connect_ssh() + thread = widget._connecting + widget.close() + assert is_kept(thread) + client.closed = False # _cleanup closed it once; the late connect must close it again + release.set() + _wait_for(lambda: client.closed and not is_kept(thread)) + + +class FakeManager: + """The SFTP wrapper, with a connect that waits for *release*.""" + + def __init__(self, release: threading.Event, error: Exception | None = None) -> None: + self.release = release + self.error = error + self.connect_thread: QThread | None = None + self.closes = 0 + + def connect(self, *_args, **_kwargs) -> None: + self.connect_thread = QThread.currentThread() + self.release.wait(WAIT_SECONDS) + if self.error is not None: + raise self.error + + def close(self) -> None: + self.closes += 1 + + @property + def connected(self) -> bool: + return False + + +def _tree(manager: FakeManager): + widget = tree_mod.SSHFileTreeManager() + widget.login_widget.host_edit.setText("host") + widget.login_widget.user_edit.setText("user") + widget.client = manager + return widget + + +class TestFileTreeConnect: + def test_the_root_is_listed_on_the_ui_thread_after_the_connect(self, app): + release = threading.Event() + manager = FakeManager(release) + widget = _tree(manager) + listed = [] + widget.load_root = lambda path: listed.append((path, QThread.currentThread())) + + widget._connect() + assert not listed + release.set() + _wait_for(lambda: listed) + + assert manager.connect_thread is not app.thread() + assert listed == [("/", app.thread())] + widget.close() + + def test_a_failed_connect_is_reported(self, app, monkeypatch): + release = threading.Event() + release.set() + widget = _tree(FakeManager(release, OSError("refused"))) + shown = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args[2])) + + widget._connect() + _wait_for(lambda: shown) + + assert "refused" in shown[0] + widget.close() + + def test_closing_while_connecting_closes_the_late_session(self, app): + release = threading.Event() + manager = FakeManager(release) + widget = _tree(manager) + widget.load_root = lambda path: pytest.fail("listed after close") + + widget._connect() + thread = widget._connecting + widget.close() + closes_at_close = manager.closes + release.set() + _wait_for(lambda: not is_kept(thread)) + + assert manager.closes > closes_at_close + + +class LateSession(FakeClient): + """A paramiko client that logs in after *release* and opens SFTP.""" + + def __init__(self, release: threading.Event) -> None: + super().__init__(release) + self.sftp_opened = False + + def get_transport(self) -> None: + return None + + def open_sftp(self) -> object: + self.sftp_opened = True + return object() + + +class TestTheRealWrapperGivenUpOn: + """Closed while it connects, the SFTP wrapper closes the session it still brings up.""" + + def _connect_in_background(self, monkeypatch, client: FakeClient): + monkeypatch.setattr(sftp_session.paramiko, "SSHClient", lambda: client) + monkeypatch.setattr(sftp_session, "apply_host_key_policy", lambda _client, _parent: None) + wrapper = sftp_session.SFTPClientWrapper() + raised: list = [] + + def connect() -> None: + try: + wrapper.connect("host", 22, "user", "pw") + except Exception as error: # noqa: BLE001 — the test records what the connect raised + raised.append(error) + else: + raised.append(None) + + worker = threading.Thread(target=connect) + worker.start() + return wrapper, raised, worker + + def test_a_session_up_after_close_is_closed_and_not_kept(self, app, monkeypatch): + release = threading.Event() + client = LateSession(release) + wrapper, raised, worker = self._connect_in_background(monkeypatch, client) + _wait_for(lambda: client.connect_thread is not None) + + wrapper.close() # Disconnect during the TCP connect: nothing to close yet + client.closed = False + release.set() + worker.join(WAIT_SECONDS) + + # It used to log in, fail on the emptied wrapper with an AttributeError + # no one caught, and leave the session open until the IDE exited. + assert isinstance(raised[0], sftp_session.ConnectAbandoned) + assert client.closed + assert not wrapper.connected + + def test_a_connect_left_alone_is_kept(self, app, monkeypatch): + release = threading.Event() + release.set() + client = LateSession(release) + wrapper, raised, worker = self._connect_in_background(monkeypatch, client) + worker.join(WAIT_SECONDS) + + assert raised == [None] + assert wrapper.connected and not client.closed + + def test_disconnect_while_connecting_reports_no_failure(self, app, monkeypatch): + release = threading.Event() + widget = _tree(FakeManager(release, OSError("closed under it"))) + shown = [] + monkeypatch.setattr(tree_mod.QMessageBox, "critical", lambda *args: shown.append(args)) + + widget._connect() + thread = widget._connecting + widget._disconnect() + release.set() + _wait_for(lambda: not is_kept(thread)) + QApplication.processEvents() + + assert shown == [] + assert widget._connecting is None # Connect can be clicked again + widget.close() + + +class TestHostKeyAsker: + """The unknown-host question is shown on the UI thread whoever asks it.""" + + def _asker(self, monkeypatch, answer): + shown_on = [] + + def exec_(_box): + shown_on.append(QThread.currentThread()) + return answer + + monkeypatch.setattr(host_key_mod.QMessageBox, "exec", exec_) + return host_key_mod.HostKeyAsker(), shown_on + + def test_asked_on_the_ui_thread_it_answers_directly(self, app, monkeypatch): + asker, shown_on = self._asker(monkeypatch, host_key_mod.QMessageBox.StandardButton.Yes) + + assert asker.ask(None, "title", "message") is True + assert shown_on == [app.thread()] + + def test_asked_from_a_connecting_thread_it_waits_for_the_ui(self, app, monkeypatch): + asker, shown_on = self._asker(monkeypatch, host_key_mod.QMessageBox.StandardButton.No) + answers = [] + worker = threading.Thread(target=lambda: answers.append(asker.ask(None, "title", "message"))) + + worker.start() + _wait_for(lambda: answers) + worker.join(WAIT_SECONDS) + + assert answers == [False] + assert shown_on == [app.thread()] + + +class TestSha1IsRefused: + """Every connect refuses SHA-1, whichever paramiko is installed (CVE-2026-44405).""" + + def test_the_shell_refuses_it(self, app, monkeypatch): + client = FakeClient() + widget = _shell(monkeypatch, client) + widget._start_shell = lambda *args: None + + widget.connect_ssh() + _wait_for(lambda: client.connect_options and not widget._connecting.isRunning()) + + assert client.connect_options["disabled_algorithms"] is shell_mod.SHA1_ALGORITHMS + + def test_the_file_tree_refuses_it(self, app, monkeypatch): + connects = [] + + class Client: + def set_missing_host_key_policy(self, _policy) -> None: + """Nothing to set in a stand-in.""" + + def load_host_keys(self, _path) -> None: + """Nothing to load in a stand-in.""" + + def connect(self, **options) -> None: + connects.append(options) + raise OSError("stop here") + + def close(self) -> None: + """Nothing to close.""" + + monkeypatch.setattr(sftp_session.paramiko, "SSHClient", Client) + monkeypatch.setattr(sftp_session, "apply_host_key_policy", lambda _client, _parent: None) + + with pytest.raises(OSError): + sftp_session.SFTPClientWrapper().connect("host", 22, "user", "pw") + + assert connects[0]["disabled_algorithms"] is sftp_session.SHA1_ALGORITHMS diff --git a/test/test_utils/test_ssh_security.py b/test/test_utils/test_ssh_security.py index f87e249c..44644b7c 100644 --- a/test/test_utils/test_ssh_security.py +++ b/test/test_utils/test_ssh_security.py @@ -4,7 +4,9 @@ import hashlib import paramiko +import pytest +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_connect_thread import SHA1_ALGORITHMS from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import _fingerprint_sha256 from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import load_private_key @@ -48,3 +50,97 @@ def test_encrypted_key_requires_correct_passphrase(self, tmp_path): paramiko.RSAKey.generate(2048).write_private_key_file(str(path), password="secret") assert load_private_key(str(path), "wrong") is None assert isinstance(load_private_key(str(path), "secret"), paramiko.RSAKey) + + +def _encrypted_key_file(tmp_path, kind: str): + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa + + key, key_format = { + "rsa-openssh": (rsa.generate_private_key(65537, 2048), serialization.PrivateFormat.OpenSSH), + "ed25519-openssh": (ed25519.Ed25519PrivateKey.generate(), serialization.PrivateFormat.OpenSSH), + "rsa-pem": (rsa.generate_private_key(65537, 2048), serialization.PrivateFormat.TraditionalOpenSSL), + "ecdsa-pem": (ec.generate_private_key(ec.SECP256R1()), serialization.PrivateFormat.TraditionalOpenSSL), + }[kind] + path = tmp_path / kind + path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, key_format, serialization.BestAvailableEncryption(b"right"))) + return str(path) + + +class TestWhyAKeyDidNotLoad: + """A wrong or missing passphrase was reported as an unsupported key.""" + + @pytest.mark.parametrize("kind", ["rsa-openssh", "ed25519-openssh", "rsa-pem", "ecdsa-pem"]) + def test_the_passphrase_is_named(self, tmp_path, kind): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import ( + PASSPHRASE_NEEDED, PASSPHRASE_WRONG, unloadable_key_reason + ) + + path = _encrypted_key_file(tmp_path, kind) + + assert load_private_key(path, "right") is not None + assert load_private_key(path, "wrong") is None + assert unloadable_key_reason(path, "wrong") == PASSPHRASE_WRONG + assert load_private_key(path, "") is None + assert unloadable_key_reason(path, "") == PASSPHRASE_NEEDED + + def test_an_encrypted_key_paramiko_cannot_load_is_unsupported_with_the_right_passphrase(self, tmp_path): + # A DSA key in OpenSSH format asks for a passphrase too: the right one + # was reported as wrong + import warnings + + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import dsa + + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import ( + PASSPHRASE_WRONG, UNSUPPORTED_KEY, unloadable_key_reason + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # cryptography deprecates DSA + data = dsa.generate_private_key(1024).private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, + serialization.BestAvailableEncryption(b"right")) + path = tmp_path / "id_dsa" + path.write_bytes(data) + + assert load_private_key(str(path), "right") is None + assert unloadable_key_reason(str(path), "right") == UNSUPPORTED_KEY + assert unloadable_key_reason(str(path), "wrong") == PASSPHRASE_WRONG + + def test_a_file_that_is_no_key_is_unsupported(self, tmp_path): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_key_loader import UNSUPPORTED_KEY, unloadable_key_reason + + path = tmp_path / "not_a_key" + path.write_text("hello", encoding="utf-8") + + assert unloadable_key_reason(str(path), "anything") == UNSUPPORTED_KEY + assert unloadable_key_reason(str(tmp_path / "missing"), "") == UNSUPPORTED_KEY + + def test_each_reason_has_a_message(self): + from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict as english_word_dict + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as traditional_chinese_word_dict, + ) + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_key_loader + + for key in (ssh_key_loader.UNSUPPORTED_KEY, ssh_key_loader.PASSPHRASE_NEEDED, ssh_key_loader.PASSPHRASE_WRONG): + assert english_word_dict.get(key) and traditional_chinese_word_dict.get(key) + + +class TestSha1Algorithms: + def test_a_transport_given_them_offers_no_sha1(self): + import socket + + ours, theirs = socket.socketpair() + transport = paramiko.Transport(ours, disabled_algorithms=SHA1_ALGORITHMS) + try: + offered = (*transport.preferred_keys, *transport.preferred_kex, *transport.preferred_pubkeys) + finally: + transport.close() + theirs.close() + + assert [name for name in offered if "sha1" in name or name.startswith("ssh-rsa")] == [] + # RSA keys still work, signed with SHA-2 + assert "rsa-sha2-256" in transport.preferred_pubkeys diff --git a/test/test_utils/test_ssh_teardown.py b/test/test_utils/test_ssh_teardown.py new file mode 100644 index 00000000..850ecd31 --- /dev/null +++ b/test/test_utils/test_ssh_teardown.py @@ -0,0 +1,332 @@ +"""Closing an SSH tab, and what the SFTP wrapper does when nothing is connected. + +A closed tab must leave no reader thread running and no transport open: Qt aborts +the process when a running QThread is destroyed, and a queued signal from one +lands in a widget that is already gone. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh.sftp_session import SFTPClientWrapper + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class FakeReaderThread: + def __init__(self) -> None: + self.stopped = False + self.waited = False + self.signals_blocked = False + + def blockSignals(self, blocked: bool) -> None: + self.signals_blocked = blocked + + def stop(self) -> None: + self.stopped = True + + def wait(self, _timeout: int) -> bool: + self.waited = True + return True + + +class FakeChannel: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + +class FakeClient: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + +class TestClosingTheShell: + def _connected_widget(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget + + widget = SSHCommandWidget() + widget.reader_thread = FakeReaderThread() + widget.shell_channel = FakeChannel() + widget.ssh_client = FakeClient() + return widget + + def test_closing_the_widget_stops_the_reader_and_the_session(self, app): + widget = self._connected_widget(app) + reader, channel, client = widget.reader_thread, widget.shell_channel, widget.ssh_client + + widget.close() + + assert reader.stopped and reader.waited + assert channel.closed + assert client.closed + assert widget.reader_thread is None + + def test_closing_a_widget_that_never_connected_is_harmless(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import SSHCommandWidget + + SSHCommandWidget().close() + + +class TestClosingTheFileTree: + def test_closing_the_widget_closes_the_sftp_session(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + + widget = SSHFileTreeManager() + widget.client = FakeClient() + + widget.close() + + assert widget.client.closed + + +class TestClosingTheTab: + def test_both_halves_are_closed_with_it(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget + + tab = SSHMainWidget() + tab.command_widget.reader_thread = FakeReaderThread() + tab.command_widget.ssh_client = FakeClient() + tab.file_tree.client = FakeClient() + reader = tab.command_widget.reader_thread + + tab.close() + + assert reader.stopped + assert tab.command_widget.ssh_client is None + assert tab.file_tree.client.closed + + +class TestTheSftpWrapperWithoutAConnection: + @pytest.mark.parametrize("call", [ + lambda w: w.mkdir("/tmp/x"), + lambda w: w.remove_file("/tmp/x"), + lambda w: w.remove_dir("/tmp/x"), + lambda w: w.rename("/tmp/x", "/tmp/y"), + lambda w: w.download("/tmp/x", "x"), + lambda w: w.upload("x", "/tmp/x"), + lambda w: w.list_dir("/tmp"), + ], ids=["mkdir", "remove_file", "remove_dir", "rename", "download", "upload", "list_dir"]) + def test_it_says_it_is_not_connected(self, app, call): + # Not an AttributeError about NoneType, which the tree's blanket handler + # would put in front of the user word for word. + wrapper = SFTPClientWrapper() + + with pytest.raises(RuntimeError): + call(wrapper) + + +class TestATransfer: + """A transfer runs in the background: a big file must not hold the IDE.""" + + def _tree(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_file_viewer_widget import SSHFileTreeManager + + return SSHFileTreeManager() + + def test_a_download_does_not_hold_the_caller(self, app, monkeypatch): + import threading + + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + answering = threading.Event() + started = threading.Event() + shown: list = [] + monkeypatch.setattr( + viewer.QMessageBox, "information", + staticmethod(lambda *args: shown.append(args))) + tree = self._tree(app) + + def slow_download(_remote, _local, _progress=None): + started.set() + answering.wait(10) + + tree.client.download = slow_download + + assert tree._start_transfer( + downloading=True, remote_path="/tmp/big.bin", local_path="big.bin", + title="Downloaded", message="Saved to") is True + + assert started.wait(5), "the transfer never started" + assert tree._transfer.isRunning(), "the caller waited for the transfer" + answering.set() + tree._transfer.wait(5000) + app.processEvents() # deliver its "done" while the message box is still stubbed + + def test_connect_does_not_cut_a_transfer_short(self, app, monkeypatch): + # A connect starts by closing the session; the combined view's shared + # Connect button, clicked to bring the shell back, killed the transfer + import threading + + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + answering = threading.Event() + said: list = [] + monkeypatch.setattr(viewer.QMessageBox, "information", staticmethod(lambda *args: said.append(args))) + tree = self._tree(app) + tree.login_widget.host_edit.setText("host") + tree.login_widget.user_edit.setText("user") + tree.client.download = lambda _remote, _local: answering.wait(10) + connects: list = [] + tree.client.connect = lambda *args, **kwargs: connects.append(args) + tree._start_transfer(downloading=True, remote_path="/tmp/big.bin", local_path="big.bin", + title="Downloaded", message="Saved to") + + tree._connect() + + assert tree._connecting is None and connects == [] + assert said, "the user was not told why nothing happened" + answering.set() + tree._transfer.wait(5000) + app.processEvents() + + def test_a_second_transfer_is_refused_while_one_runs(self, app, monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + said: list = [] + monkeypatch.setattr( + viewer.QMessageBox, "information", staticmethod(lambda *args: said.append(args))) + tree = self._tree(app) + + class Running: + @staticmethod + def isRunning() -> bool: + return True + + tree._transfer = Running() + + assert tree._start_transfer( + downloading=True, remote_path="/tmp/x", local_path="x", + title="Downloaded", message="Saved to") is False + assert said, "the user was told nothing" + + def test_a_transfer_that_fails_is_reported(self, app, monkeypatch): + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + + problems: list = [] + monkeypatch.setattr( + viewer.QMessageBox, "critical", staticmethod(lambda *args: problems.append(args))) + tree = self._tree(app) + tree.client.download = lambda *_args: (_ for _ in ()).throw(OSError("link went away")) + thread = viewer.SftpTransferThread(tree.client, True, "/tmp/x", "x") + thread.failed.connect(tree._transfer_failed) + + thread.run() + + assert problems and "link went away" in problems[0][2] + + def test_closing_leaves_a_transfer_to_finish_then_closes_the_session(self, app, monkeypatch): + import threading + import time + + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_file_viewer_widget as viewer + from pybreeze.pybreeze_ui.thread_keeper import is_kept + + # Nothing may be shown, but a stray signal must not open a modal box. + monkeypatch.setattr(viewer.QMessageBox, "information", staticmethod(lambda *args: None)) + monkeypatch.setattr(viewer.QMessageBox, "critical", staticmethod(lambda *args: None)) + answering = threading.Event() + tree = self._tree(app) + tree.client = FakeClient() + tree.client.download = lambda _remote, _local: answering.wait(5) + tree._start_transfer( + downloading=True, remote_path="/tmp/big.bin", local_path="big.bin", + title="Downloaded", message="Saved to") + transfer = tree._transfer + + tree.close() # returns with the transfer still going + + assert is_kept(transfer) + # Closing the session under the transfer would leave half a file. + assert not tree.client.closed + answering.set() + deadline = time.monotonic() + 5 + while not tree.client.closed or is_kept(transfer): + assert time.monotonic() < deadline, "the session was never closed" + app.processEvents() + time.sleep(0.01) + + +class TestWhatTheStatusLabelSays: + """One Connect button opens two sessions; the label has to report both.""" + + def _tab(self, shell_up: bool, files_up: bool): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget + + tab = SSHMainWidget() + tab.command_widget.is_connected = lambda: shell_up + + class Client: + connected = files_up + + @staticmethod + def close() -> None: + """Nothing to close in a stand-in.""" + + tab.file_tree.client = Client() + return tab + + @pytest.mark.parametrize("shell_up,files_up,expected", [ + (True, True, "shell and files"), + (True, False, "shell only"), + (False, True, "files only"), + (False, False, "Disconnected"), + ], ids=["both", "shell only", "files only", "neither"]) + def test_it_reports_both_halves(self, app, shell_up, files_up, expected): + tab = self._tab(shell_up, files_up) + + tab.report_connection_state() + + assert expected in tab.login_widget.status_label.text() + + +class TestTheShellEndingOnTheServer: + """``exit`` in the shell, or a dropped link: the reader reports the channel closed.""" + + def test_the_session_is_closed_with_it(self, app): + widget = TestClosingTheShell()._connected_widget(app) + channel, client = widget.shell_channel, widget.ssh_client + + widget._on_closed("EOF") + + # It used to stay open, sending keepalives, until the next Connect. + assert channel.closed and client.closed + assert widget.ssh_client is None + + def test_the_shared_label_still_reports_a_connected_file_tree(self, app): + from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_main_widget import SSHMainWidget + + tab = SSHMainWidget() + tab.command_widget.reader_thread = FakeReaderThread() + tab.command_widget.shell_channel = FakeChannel() + tab.command_widget.ssh_client = FakeClient() + + class Client: + connected = True + + @staticmethod + def close() -> None: + """Nothing to close in a stand-in.""" + + tab.file_tree.client = Client() + + tab.command_widget._on_closed("EOF") + + # It used to say "Disconnected" with the file tree still up. + assert "files only" in tab.login_widget.status_label.text() diff --git a/test/test_utils/test_ssh_terminal_output.py b/test/test_utils/test_ssh_terminal_output.py new file mode 100644 index 00000000..3f1a9eee --- /dev/null +++ b/test/test_utils/test_ssh_terminal_output.py @@ -0,0 +1,229 @@ +"""What the shell sends, shown as it was sent: reads may stop anywhere.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import paramiko +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_command_widget import ( + SSHCommandWidget, + SSHReaderThread, + TerminalDecoder, +) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +class TestTerminalDecoder: + @pytest.mark.parametrize("cut", range(1, 6)) + def test_a_character_cut_between_reads_is_joined(self, cut): + data = "中文".encode("utf-8") # six bytes, two characters + decoder = TerminalDecoder() + + assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "中文" + + @pytest.mark.parametrize("cut", range(1, 5)) + def test_an_escape_cut_between_reads_is_still_removed(self, cut): + data = b"\x1b[31mred" + decoder = TerminalDecoder() + + assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "red" + + @pytest.mark.parametrize("data", [b"\x1b(Bok", b"\x1b]0;title\x1b\\ok", b"\x1bP1$r0m\x1b\\ok"]) + def test_a_character_set_or_string_escape_cut_anywhere_is_removed(self, data): + # A read ending between the ESC and the backslash of ST showed "\ok" + for cut in range(1, len(data) - 2): + decoder = TerminalDecoder() + + assert decoder.feed(data[:cut]) + decoder.feed(data[cut:]) == "ok", cut + + def test_text_before_an_unfinished_escape_is_shown_now(self): + decoder = TerminalDecoder() + + assert decoder.feed(b"ready \x1b[") == "ready " + assert decoder.feed(b"0mgo") == "go" + + def test_a_very_long_unterminated_sequence_is_not_held_forever(self): + decoder = TerminalDecoder() + title = b"\x1b]0;" + b"t" * 400 + + decoder.feed(title) + + assert decoder._pending == "" + + def test_reset_forgets_what_was_carried_over(self): + decoder = TerminalDecoder() + decoder.feed("中".encode("utf-8")[:2]) + decoder.feed(b"\x1b[") + + decoder.reset() + + assert decoder.feed(b"plain") == "plain" + + +class TestTheTerminal: + def _widget(self): + return SSHCommandWidget() + + def test_reads_continue_the_line_they_belong_to(self, app): + widget = self._widget() + + for chunk in (b"ab", b"cd\r\n", b"ef"): + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "abcd\nef" + widget.close() + + def test_a_backspace_takes_back_what_an_earlier_read_showed(self, app): + # A spinner's frames in separate reads showed "|/-done" + widget = self._widget() + + for chunk in (b"line\r\n", b"|", b"\x08/", b"\x08\x1b[1m-", b"\x08\x08\x08done\r\n"): + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "line\ndone\n" + widget.close() + + def test_a_line_ending_cut_between_reads_is_one_line_break(self, app): + # "\r" at the end of one read and "\n" at the start of the next were + # two paragraph breaks: a blank line in long output + widget = self._widget() + + for chunk in (b"line1\r", b"\nline2\r\n"): + widget._on_data(chunk) + + assert widget.terminal.toPlainText() == "line1\nline2\n" + widget.close() + + def test_a_notice_starts_on_a_line_of_its_own(self, app): + widget = self._widget() + widget._on_data(b"$ ") + + widget.append_text("Disconnecting\n") + + assert widget.terminal.toPlainText() == "$ \nDisconnecting\n" + widget.close() + + def test_a_notice_after_a_finished_line_adds_no_blank_line(self, app): + widget = self._widget() + widget._on_data(b"done\r\n") + + widget.append_text("Disconnecting\n") + + assert widget.terminal.toPlainText() == "done\nDisconnecting\n" + widget.close() + + +class FakeChannel: + """A channel whose shell has exited with output still buffered.""" + + closed = False + + def __init__(self, chunks: list[bytes]) -> None: + self.chunks = chunks + + def recv_ready(self) -> bool: + return bool(self.chunks) + + def recv(self, _size: int) -> bytes: + return self.chunks.pop(0) + + def recv_stderr_ready(self) -> bool: + return False + + def exit_status_ready(self) -> bool: + return True + + +class TestTheReader: + def test_output_buffered_when_the_shell_exits_is_not_lost(self, app): + reader = SSHReaderThread(FakeChannel([b"last ", b"words"])) + received: list[bytes] = [] + reader.data_received.connect(received.append) + + reader.run() # on this thread: direct connections + + assert b"".join(received) == b"last words" + + +class PartialSendChannel: + """Takes at most one packet per send, as paramiko's Channel.send does.""" + + closed = False + sendall = paramiko.Channel.sendall + + def __init__(self) -> None: + self.sent: list[bytes] = [] + self.timeouts: list = [] + + def send(self, data) -> int: + chunk = bytes(data[:32704]) + self.sent.append(chunk) + return len(chunk) + + def settimeout(self, timeout) -> None: + self.timeouts.append(timeout) + + +class TestSendingACommand: + def test_a_long_command_arrives_whole_as_utf8(self, app): + # Past one packet the rest and the newline were dropped, and a str was + # counted in characters, not the bytes that go out + widget = SSHCommandWidget() + channel = PartialSendChannel() + widget.shell_channel = channel + # 20,005 characters (within QLineEdit's 32,767), 60,006 bytes: two packets + command = "echo " + "中文" * 10000 + widget.command_input_edit.setText(command) + + widget.send_command() + + assert b"".join(channel.sent) == (command + "\n").encode("utf-8") + assert channel.timeouts == [5, 0.0] + assert widget.command_input_edit.text() == "" + widget.shell_channel = None + widget.close() + + +class _IdleReader: + """A reader that is never started: the connect message is all that is looked at.""" + + def __init__(self, _channel) -> None: + self.data_received = self.closed = self + + def connect(self, _slot) -> None: + """No signal is ever sent.""" + + def start(self) -> None: + """Nothing to read.""" + + +class TestTheConnectMessage: + """It read "Connected to host:22 as user" with " as " in English, and the status "Connected to".""" + + @pytest.mark.parametrize(("host", "shown"), [("example.org", "example.org:22"), ("::1", "[::1]:22")]) + def test_names_the_session_in_the_ide_language(self, app, monkeypatch, host, shown): + from pybreeze.extend_multi_language.extend_traditional_chinese import ( + pybreeze_traditional_chinese_word_dict as word, + ) + from pybreeze.pybreeze_ui.connect_gui.ssh import ssh_command_widget as shell_mod + monkeypatch.setattr(shell_mod, "SSHReaderThread", _IdleReader) + widget = SSHCommandWidget() + widget.word_dict = word + + widget._start_shell(object(), host, 22, "alice") + + assert widget.terminal.toPlainText().endswith(f"已以 alice 身分連線至 {shown}\n") + assert widget.login_widget.status_label.text() == "已連線" + widget.shell_channel = widget.reader_thread = None + widget.close() diff --git a/test/test_utils/test_started_menus.py b/test/test_utils/test_started_menus.py new file mode 100644 index 00000000..5149a0e1 --- /dev/null +++ b/test/test_utils/test_started_menus.py @@ -0,0 +1,75 @@ +"""Every submenu of the started IDE still has entries once garbage is collected. + +A QAction added to a menu is not owned by the menu. One built with no parent and +kept only in a local variable is deleted when its builder returns, and the menu +it was added to is left empty: that is how every Run, HELP and Project submenu +of the automation menus came up empty. + +JEditor's two font menus are left out: they list the platform's font families, +and the offscreen platform the tests run on has none. +""" +from __future__ import annotations + +from test_utils.started_window import run_started_window + +_REPORT_EMPTY_SUBMENUS = """ +font_menus = [window.file_menu.font_menu, window.text_menu.font_menu] + +def empty_submenus(menu, path): + empty = [] + for action in menu.actions(): + submenu = action.menu() + if submenu is None or submenu in font_menus: + continue + where = path + [action.text()] + if not submenu.actions(): + empty.append(" > ".join(where)) + empty.extend(empty_submenus(submenu, where)) + return empty + +result = { + "automation_entries": len(window.automation_menu.actions()), + "empty": [ + entry + for top in window.menuBar().actions() if top.menu() is not None + for entry in empty_submenus(top.menu(), [top.text()]) + ], +} +""" + + +def test_no_submenu_of_the_started_ide_is_empty(tmp_path): + seen = run_started_window(tmp_path, _REPORT_EMPTY_SUBMENUS) + + assert seen["automation_entries"] > 0 + assert seen["empty"] == [] + + + +_REGISTER_A_BROKEN_AND_A_FINE_TAB = """ +from pybreeze import EDITOR_EXTEND_TAB +from PySide6.QtWidgets import QWidget + +class Broken(QWidget): + def __init__(self): + raise RuntimeError("a third-party tab that fails") + +class Fine(QWidget): + pass + +EDITOR_EXTEND_TAB.update({"broken": Broken, "fine": Fine}) +""" + +_REPORT_THE_TABS = """ +result = [window.tab_widget.tabText(index) for index in range(window.tab_widget.count())] +""" + + +def test_a_broken_extend_tab_costs_only_itself(tmp_path): + # EDITOR_EXTEND_TAB is open to third parties: one constructor that raises + # must not keep the IDE from starting. + tabs = run_started_window( + tmp_path, _REPORT_THE_TABS, before_window=_REGISTER_A_BROKEN_AND_A_FINE_TAB) + + assert "fine" in tabs + assert "broken" not in tabs diff --git a/test/test_utils/test_startup_language.py b/test/test_utils/test_startup_language.py new file mode 100644 index 00000000..43d6ab32 --- /dev/null +++ b/test/test_utils/test_startup_language.py @@ -0,0 +1,31 @@ +"""Starting the real main window with a language other than English saved. + +JEditor picks the saved language inside ``EditorMain.__init__`` and serves it +from a merged copy of its dictionaries. PyBreeze's strings have to be in by +then; when they were added afterwards, every PyBreeze menu got a ``None`` title +and Qt crashed the process (access violation) before the window appeared. +""" +from __future__ import annotations + +import pytest + +from test_utils.started_window import run_started_window + +_REPORT_WHAT_STARTED = """ +result = { + "language": language_wrapper.language, + "menu_titles": [action.text() for action in window.menuBar().actions()], + "automation_menu": window.automation_menu.title(), + "window_title": window.windowTitle(), +} +""" + + +@pytest.mark.parametrize("language", ["Traditional_Chinese", "Japanese"]) +def test_the_ide_starts_with_the_saved_language(tmp_path, language): + seen = run_started_window(tmp_path, _REPORT_WHAT_STARTED, saved_language=language) + + assert seen["language"] == language + assert all(seen["menu_titles"]), seen["menu_titles"] + assert seen["automation_menu"] + assert seen["window_title"] == "PyBreeze" diff --git a/test/test_utils/test_syntax_extend.py b/test/test_utils/test_syntax_extend.py new file mode 100644 index 00000000..af34d80b --- /dev/null +++ b/test/test_utils/test_syntax_extend.py @@ -0,0 +1,51 @@ +"""Automation keyword highlighting is registered for every suffix the IDE runs.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from je_editor.plugins import get_programming_language_plugin + +from pybreeze.pybreeze_ui.syntax.syntax_extend import syntax_extend_package +from pybreeze.pybreeze_ui.syntax.syntax_keyword import TEST_PIONEER_SUFFIXES, package_keyword_list + + +class _Tabs: + @staticmethod + def currentWidget(): + return None + + +class _MainWindow: + tab_widget = _Tabs() + + +@pytest.fixture(scope="module") +def registered(): + syntax_extend_package(_MainWindow()) + + +@pytest.mark.parametrize("suffix", [".yml", ".yaml"]) +def test_a_test_pioneer_script_is_highlighted_under_either_yaml_suffix(registered, suffix): + # ".yaml" scripts ran from the TestPioneer menu but were shown plain + plugin = get_programming_language_plugin(suffix) + + assert plugin is not None + assert plugin["syntax_words"]["test_pioneer"]["words"] == set(package_keyword_list["test_pioneer"]) + + +def test_the_menu_runs_exactly_the_highlighted_suffixes(): + from pybreeze.pybreeze_ui.menu.automation_menu.test_pioneer_menu import build_test_pioneer_menu as menu + + assert menu._YAML_FILTER == "YAML (*.yml *.yaml)" + assert TEST_PIONEER_SUFFIXES == (".yml", ".yaml") + + +def test_every_automation_package_gets_json_keywords(registered): + from pybreeze.utils.manager.package_manager.package_manager_class import package_manager + + groups = get_programming_language_plugin(".json")["syntax_words"] + + assert set(groups) == set(package_manager.syntax_check_list) diff --git a/test/test_utils/test_task_manager_venv.py b/test/test_utils/test_task_manager_venv.py index f5420a0a..98cf5b44 100644 --- a/test/test_utils/test_task_manager_venv.py +++ b/test/test_utils/test_task_manager_venv.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import sys +from pathlib import Path os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -20,7 +22,7 @@ def qt_app(): def _raise_no_python(_path): - from je_editor.utils.exception.exceptions import JEditorExecException + from je_editor import JEditorExecException raise JEditorExecException("no python interpreter found") @@ -36,7 +38,9 @@ def test_returns_false_without_crashing(self, qt_app, monkeypatch): manager = TaskProcessManager.__new__(TaskProcessManager) manager.main_window = window - monkeypatch.setattr(mod, "find_venv_path", lambda: "ignored") + # Only a packaged build, with no venv, can find no Python at all + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(mod, "find_venv_path", lambda: Path("ignored")) monkeypatch.setattr(mod, "check_and_choose_venv", _raise_no_python) # Must report (not raise) so the run menu callback does not crash. diff --git a/test/test_utils/test_test_pioneer_template.py b/test/test_utils/test_test_pioneer_template.py new file mode 100644 index 00000000..f4b0a1df --- /dev/null +++ b/test/test_utils/test_test_pioneer_template.py @@ -0,0 +1,85 @@ +"""TestPioneer's Create template: where it writes, and what it does to a template already there.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.test_pioneer_menu import build_test_pioneer_menu as menu + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def window(app, tmp_path, monkeypatch): + made = QMainWindow() + made.working_dir = str(tmp_path / "project") + (tmp_path / "project").mkdir() + made.told = [] + for kind in ("information", "warning"): + monkeypatch.setattr( + QMessageBox, kind, + staticmethod(lambda *a, kind=kind, **k: made.told.append((kind, a[2])))) + yield made + made.deleteLater() + + +def _template(window): + from pathlib import Path + + return Path(window.working_dir) / ".TestPioneer" / ".TestPioneer.yml" + + +def test_it_goes_in_the_ide_working_directory(window, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + menu.create_template(window) + + assert _template(window).is_file() + assert not (tmp_path / ".TestPioneer").exists() + assert window.told[-1][0] == "information" + + +def test_an_edited_template_is_kept_unless_the_user_says_so(window, monkeypatch): + template = _template(window) + template.parent.mkdir() + template.write_text("my edited steps", encoding="utf-8") + monkeypatch.setattr( + QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)) + + menu.create_template(window) + + # A second click used to overwrite it without asking. + assert template.read_text(encoding="utf-8") == "my edited steps" + + +def test_saying_yes_replaces_it(window, monkeypatch): + template = _template(window) + template.parent.mkdir() + template.write_text("my edited steps", encoding="utf-8") + monkeypatch.setattr( + QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)) + + menu.create_template(window) + + assert template.read_text(encoding="utf-8") != "my edited steps" + + +def test_a_failed_write_is_reported_not_raised(window, monkeypatch): + def refuse(*_args, **_kwargs): + raise OSError(13, "Access is denied") + + monkeypatch.setattr(menu, "create_template_dir", refuse) + + menu.create_template(window) + + assert window.told and window.told[-1][0] == "warning" diff --git a/test/test_utils/test_testpioneer_menu.py b/test/test_utils/test_testpioneer_menu.py new file mode 100644 index 00000000..9d7a42a0 --- /dev/null +++ b/test/test_utils/test_testpioneer_menu.py @@ -0,0 +1,86 @@ +"""Running a TestPioneer script from its menu.""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.menu.automation_menu.test_pioneer_menu import build_test_pioneer_menu as menu + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def chosen(monkeypatch) -> dict: + """Answer the file dialog with ``chosen["path"]``; record the filter and what ran.""" + state: dict = {"path": "", "ran": [], "filter": None, "told": 0} + + def get_open_file_name(_parent=None, *args, filter="", **kwargs): + state["filter"] = filter + return state["path"], filter + + class Message: + def __init__(self, _parent=None) -> None: + """Collects nothing but the fact it was shown.""" + + def setWindowTitle(self, _title) -> None: + """Title is not checked.""" + + def setText(self, _text) -> None: + """Text is not checked.""" + + def setAttribute(self, _attribute) -> None: + """Deleted once answered (test_answered_boxes_are_deleted.py checks it).""" + + def exec(self) -> None: + state["told"] += 1 + + monkeypatch.setattr(menu.QFileDialog, "getOpenFileName", staticmethod(get_open_file_name)) + monkeypatch.setattr(menu, "QMessageBox", Message) + monkeypatch.setattr( + menu, "init_and_start_test_pioneer_process", lambda _window, path: state["ran"].append(path)) + return state + + +@pytest.mark.parametrize("name", ["run.yml", "run.yaml", "RUN.YAML"]) +def test_either_yaml_extension_runs(app, tmp_path, chosen, name): + script = tmp_path / name + script.write_text("jobs: []", encoding="utf-8") + chosen["path"] = str(script) + + menu.check_file(None) + + assert chosen["ran"] == [str(script)] + assert chosen["told"] == 0 + + +def test_the_dialog_is_given_the_yaml_filter(app, chosen): + menu.check_file(None) + + assert "*.yaml" in chosen["filter"] and "*.yml" in chosen["filter"] + + +def test_another_file_is_refused(app, tmp_path, chosen): + script = tmp_path / "run.txt" + script.write_text("x", encoding="utf-8") + chosen["path"] = str(script) + + menu.check_file(None) + + assert chosen["ran"] == [] + assert chosen["told"] == 1 + + +def test_cancelling_does_nothing(app, chosen): + menu.check_file(None) + + assert chosen["ran"] == [] and chosen["told"] == 0 diff --git a/test/test_utils/test_text_diff.py b/test/test_utils/test_text_diff.py index 112de0c2..776d2301 100644 --- a/test/test_utils/test_text_diff.py +++ b/test/test_utils/test_text_diff.py @@ -1,6 +1,10 @@ """Tests for the text diff utility.""" from __future__ import annotations +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + from pybreeze.utils.diff_tools.text_diff import diff_summary, unified_diff @@ -56,3 +60,166 @@ def test_changed_line_counts_as_add_and_remove(self): def test_whitespace_only_difference(self): summary = diff_summary("a", "a ") assert not summary.is_equal + + +class TestSummaryMatchesTheShownDiff: + def test_the_counts_are_the_diffs_own_plus_and_minus_lines(self): + from hypothesis import given, settings + from hypothesis import strategies as st + + lines = st.lists(st.sampled_from(["a", "b", "c", "d", ""]), max_size=12) + + @settings(max_examples=200, deadline=None) + @given(lines, lines) + def agree(left, right): + left_text, right_text = "\n".join(left), "\n".join(right) + shown = unified_diff(left_text, right_text).splitlines()[2:] # skip the ---/+++ header + summary = diff_summary(left_text, right_text) + assert summary.added == sum(line.startswith("+") for line in shown) + assert summary.removed == sum(line.startswith("-") for line in shown) + + agree() + + def test_every_line_changed_in_a_large_text_is_quick(self): + import time + + left = "\n".join(f"line {number} alpha" for number in range(3000)) + right = "\n".join(f"line {number} beta" for number in range(3000)) + + started = time.perf_counter() + summary = diff_summary(left, right) + + # ndiff took over a minute here; the opcodes take milliseconds. + assert time.perf_counter() - started < 5 + assert (summary.added, summary.removed) == (3000, 3000) + + +class TestOnlyTheLineEndingsDiffer: + """Texts that differ in a final newline or a line ending: the diff says where.""" + + def test_a_missing_final_newline_shows_as_a_change(self): + # It said "not identical" and showed an empty diff: +0 / -0. + summary = diff_summary("a\n", "a") + + assert (summary.added, summary.removed, summary.is_equal) == (1, 1, False) + assert unified_diff("a\n", "a").endswith("+a\n\\ No newline at end of file") + + def test_a_different_line_ending_is_named(self): + assert "-a (line ends with '\\r\\n')" in unified_diff("a\r\nb", "a\nb") + + def test_other_changes_are_counted_as_before(self): + # A line added after a last line without a newline is one added line. + assert diff_summary("a", "a\nb").added == 1 + + +class TestAnotherLineSeparator: + """splitlines also ends a line at U+2028, \\v, \\f, ...: the diff must name it.""" + + def test_a_line_separator_is_named_not_mistaken_for_no_newline(self): + diff = unified_diff("a
b", "a\nb") + + assert "-a (line ends with '\\u2028')" in diff + assert "No newline" not in diff + + def test_two_separators_that_look_alike_are_told_apart(self): + diff = unified_diff("a\x0bb", "a\x0cb") + + assert "-a (line ends with '\\x0b')" in diff + assert "+a (line ends with '\\x0c')" in diff + + +def test_a_removed_line_starting_with_dashes_gets_its_ending_named(): + # "---x" was taken for the diff's header line and left without a note + diff = unified_diff("--x\r\n", "--x\n") + + assert diff.splitlines()[:2] == ["--- expected", "+++ actual"] + assert "---x (line ends with '\\r\\n')" in diff + + +_LINES = st.lists(st.sampled_from(["a", "b", "c", "", "d e"]), max_size=25) + + +def _applied(diff: str, left: list[str]) -> list[str]: + """*left* with the unified *diff* applied, each context and removed line checked.""" + import re + + out: list[str] = [] + position = 0 + for line in diff.splitlines()[2:]: + if line.startswith("@@"): + start, length = re.match(r"@@ -(\d+)(?:,(\d+))? ", line).groups() + begin = int(start) - 1 if length != "0" else int(start) + out += left[position:begin] + position = begin + elif line.startswith("\\ No newline"): + continue + elif line.startswith("+"): + out.append(line[1:]) + else: + assert left[position] == line[1:] + if line.startswith(" "): + out.append(line[1:]) + position += 1 + return out + left[position:] + + +@settings(max_examples=300, deadline=None) +@given(left=_LINES, right=_LINES) +def test_the_diff_turns_the_left_into_the_right_and_is_no_longer_than_difflibs(left, right): + # Where two alignments are as short, the unchanged head and tail set aside + # first may pick the other one; the diff must still be right, and never + # longer than difflib.unified_diff's + import difflib + + from pybreeze.utils.diff_tools.text_diff import _line_lists, compare_texts + + left_text, right_text = "\n".join(left), "\n".join(right) + comparison = compare_texts(left_text, right_text) + body = comparison.diff.splitlines()[2:] + left_lines, right_lines = _line_lists(left_text, right_text) + written = list(difflib.unified_diff(left_lines, right_lines, lineterm="", n=3))[2:] + + assert _applied(comparison.diff, left_text.splitlines()) == right_text.splitlines() + assert comparison.summary.added == sum(line.startswith("+") for line in body) + assert comparison.summary.removed == sum(line.startswith("-") for line in body) + changed = comparison.summary.added + comparison.summary.removed + assert changed <= sum(line[:1] in ("+", "-") for line in written) + + +def test_a_head_set_aside_does_not_make_the_diff_longer(): + # The first "b" taken as unchanged left a worse match: five lines changed + # where difflib changes three (-b, +c, -a) + from pybreeze.utils.diff_tools.text_diff import compare_texts + + comparison = compare_texts("b\nb\na\nb\na", "b\na\nc\nb") + + assert (comparison.summary.added, comparison.summary.removed) == (1, 2) + assert _applied(comparison.diff, ["b", "b", "a", "b", "a"]) == ["b", "a", "c", "b"] + + +@pytest.mark.parametrize(("left", "right"), [ + ("\n".join(["{", " a", "}"] * 100), "\n".join(["{", " a", "}"] * 100).replace(" a", " b", 1)), + ("x\n" * 300, "y\n" + "x\n" * 299), +]) +def test_one_changed_line_in_a_long_repetitive_text_is_one_line(left, right): + # A line making up over 1% of 200 or more was junk to difflib: 299 removed and added + from pybreeze.utils.diff_tools.text_diff import compare_texts + + summary = compare_texts(left, right).summary + + assert (summary.added, summary.removed) == (1, 1) + + +def test_repetitive_text_just_under_2000_lines_is_quick(): + # The junk heuristic was turned off below 2,000 lines left to match: 55 s here + import time + + from pybreeze.utils.diff_tools.text_diff import compare_texts + + left = "".join(f"}}\na{i}\n" for i in range(998)) + right = "".join(f"}}\nb{i}\n" for i in range(998)) + started = time.monotonic() + + compare_texts(left, right) + + assert time.monotonic() - started < 5 diff --git a/test/test_utils/test_timestamp_converter.py b/test/test_utils/test_timestamp_converter.py index 9d0413de..f9059885 100644 --- a/test/test_utils/test_timestamp_converter.py +++ b/test/test_utils/test_timestamp_converter.py @@ -82,3 +82,119 @@ def test_garbage_raises(self): def test_out_of_range_epoch_raises(self): with pytest.raises(TimestampParseException): convert_timestamp("1" * 40) + + +class TestInstantsDatetimeCannotHold: + @pytest.mark.parametrize("text", ["0001-01-01T00:00:00+01:00", "9999-12-31T23:30:00-01:00"]) + def test_a_date_that_leaves_the_range_in_utc_is_a_parse_error(self, text): + # In UTC these fall before year 1 or after year 9999: OverflowError, not ValueError. + with pytest.raises(TimestampParseException): + convert_timestamp(text) + + +class TestSubSecondPrecision: + def test_milliseconds_keep_their_sub_second_part(self): + result = convert_timestamp("1700000000123") + + assert result.epoch_millis == 1700000000123 + assert result.epoch_seconds == 1700000000 + + def test_an_instant_before_1970_rounds_down_not_toward_zero(self): + result = convert_timestamp("-1.5") + + assert result.epoch_millis == -1500 + assert result.epoch_seconds == -2 + + @pytest.mark.parametrize("text,iso", [ + ("-86400", "1969-12-31T00:00:00+00:00"), + ("-315619200", "1960-01-01T00:00:00+00:00"), + ]) + def test_a_day_or_more_before_1970_converts_on_every_platform(self, text, iso): + # fromtimestamp() refused these on Windows: "not a recognized epoch number" + assert convert_timestamp(text).iso_utc == iso + + def test_an_iso_time_with_milliseconds_keeps_them(self): + assert convert_timestamp("2021-01-01T00:00:00.250Z").epoch_millis == 1609459200250 + + +class TestFormsToolsWrite: + """Read the same on Python 3.10 as on 3.14: fromisoformat took less before 3.11.""" + + @pytest.mark.parametrize("text", [ + "2024-01-01T00:00:00+0000", + "2024-01-01T00:00:00+00", + "2024-01-01T08:00:00+08", + "2024-01-01T00:00:00z", + "20240101T000000Z", + "2024-01-01 00:00:00Z", + ]) + def test_offsets_and_designators(self, text): + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).epoch_seconds == 1704067200 + + @pytest.mark.parametrize(("text", "millis"), [ + ("2024-01-01T00:00:00.1Z", 1704067200100), + ("2024-01-01T00:00:00.12Z", 1704067200120), + ("2024-01-01T00:00:00.123456789Z", 1704067200123), + ]) + def test_any_number_of_fraction_digits(self, text, millis): + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).epoch_millis == millis + + @pytest.mark.parametrize("text", ["1700000000123456", "1700000000123456789"]) + def test_microseconds_and_nanoseconds(self, text): + # Both were divided by 1000 as milliseconds and overflowed + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).epoch_millis == 1700000000123 + + def test_an_eight_digit_date_is_a_date(self): + # 20240101 was read as seconds: August 1970 + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp("20240101").iso_utc == "2024-01-01T00:00:00+00:00" + assert convert_timestamp("12345678").epoch_seconds == 12345678 # not a date: seconds + + def test_a_field_out_of_range_is_refused(self): + from pybreeze.utils.exception.exceptions import TimestampParseException + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + with pytest.raises(TimestampParseException): + convert_timestamp("2024-13-01T00:00:00Z") + + def test_an_offset_past_59_minutes_is_refused(self): + # +05:99 was taken as +06:39 + from pybreeze.utils.exception.exceptions import TimestampParseException + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + with pytest.raises(TimestampParseException): + convert_timestamp("2024-01-01T00:00+05:99") + assert convert_timestamp("2024-01-01T00:00+05:59").iso_utc == "2023-12-31T18:01:00+00:00" + + +class TestADecimalEpochRoundsTowardThePast: + """As a whole number does, and as the result's fields say: a float was rounded to the nearest.""" + + @pytest.mark.parametrize(("text", "iso"), [ + ("0.0000009", "1970-01-01T00:00:00+00:00"), + ("-0.0000001", "1969-12-31T23:59:59.999999+00:00"), + ("1700000000.9999999", "2023-11-14T22:13:20.999999+00:00"), + ("1700000000123.4567", "2023-11-14T22:13:20.123456+00:00"), + ("-1e-999999999", "1969-12-31T23:59:59.999999+00:00"), + ("0e999999999", "1970-01-01T00:00:00+00:00"), + ("0." + "9" * 70, "1970-01-01T00:00:00.999999+00:00"), # rounded up to 1 s at 60 digits + ]) + def test_it_is_cut_to_the_microsecond(self, text, iso): + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + assert convert_timestamp(text).iso_utc == iso + + @pytest.mark.parametrize("text", ["1e999999999", "Infinity", "-inf", "nan", "sNaN", "9" * 40]) + def test_one_no_date_can_be_is_refused(self, text): + from pybreeze.utils.exception.exceptions import TimestampParseException + from pybreeze.utils.timestamp_tools.timestamp_converter import convert_timestamp + + with pytest.raises(TimestampParseException): + convert_timestamp(text) diff --git a/test/test_utils/test_tools_menu_docks.py b/test/test_utils/test_tools_menu_docks.py index 2d870235..cfbb3a24 100644 --- a/test/test_utils/test_tools_menu_docks.py +++ b/test/test_utils/test_tools_menu_docks.py @@ -11,6 +11,8 @@ from je_editor.pyside_ui.main_ui.dock.destroy_dock import DestroyDock from pybreeze.extend_multi_language.update_language_dict import update_language_dict +from pybreeze.pybreeze_ui.extend_ai_gui.code_review.cot_code_review_gui import CoTCodeReviewGUI +from pybreeze.pybreeze_ui.menu.tools import tools_menu from pybreeze.pybreeze_ui.menu.tools.tools_menu import add_dock from pybreeze.pybreeze_ui.tools_gui.curl_import_gui import CurlImportGUI from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI @@ -37,6 +39,7 @@ def app(): @pytest.mark.parametrize( "widget_type,widget_class", [ + ("CoTCodeReview", CoTCodeReviewGUI), ("CurlImport", CurlImportGUI), ("HarImport", HarImportGUI), ("JwtDecoder", JwtDecoderGUI), @@ -71,3 +74,13 @@ def test_add_dock_unknown_type_adds_nothing(app): assert attached == [] finally: window.deleteLater() + + +def test_every_tool_widget_has_a_tab_a_dock_and_a_dock_title(): + # The CoT code review panel had a widget and no entry in any menu, so there + # was no way to open it. + factories = set(tools_menu._WIDGET_FACTORIES) + + assert {entry[0] for entry in tools_menu._TAB_ACTIONS} == factories + assert {entry[0] for entry in tools_menu._DOCK_ACTIONS} == factories + assert set(tools_menu._DOCK_TITLES) == factories diff --git a/test/test_utils/test_unsaved_on_close.py b/test/test_utils/test_unsaved_on_close.py new file mode 100644 index 00000000..4284603d --- /dev/null +++ b/test/test_utils/test_unsaved_on_close.py @@ -0,0 +1,152 @@ +"""Closing a tab or the IDE asks before unsaved prompt or diagram edits are lost. + +JEditor's close_tab asked about its own editor tabs only and closed every other +tab whatever it said, so a prompt or a diagram being edited was lost unasked. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def answers(monkeypatch): + """Answer every question with ``answers["reply"]`` and count them.""" + state = {"reply": QMessageBox.StandardButton.No, "asked": 0} + + def question(*_args, **_kwargs): + state["asked"] += 1 + return state["reply"] + + monkeypatch.setattr(QMessageBox, "question", staticmethod(question)) + return state + + +class TestTheDiagramEditor: + def _editor(self): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + from pybreeze.pybreeze_ui.diagram_editor.diagram_items import DiagramNode + + editor = DiagramEditorWidget() + with editor._scene.undo_scope("Add"): + editor._scene.addItem(DiagramNode(x=0, y=0)) + return editor + + def test_an_unchanged_diagram_closes_without_a_question(self, app, answers): + from pybreeze.pybreeze_ui.diagram_editor.diagram_editor_widget import DiagramEditorWidget + + assert DiagramEditorWidget().may_close() is True + assert answers["asked"] == 0 + + def test_an_unsaved_change_is_asked_about(self, app, answers): + editor = self._editor() + + assert editor.may_close() is False + answers["reply"] = QMessageBox.StandardButton.Yes + assert editor.may_close() is True + assert answers["asked"] == 2 + + def test_a_saved_diagram_closes_without_a_question(self, app, answers, tmp_path): + editor = self._editor() + editor._write_json(tmp_path / "saved.diagram.json") + + assert editor.may_close() is True + assert answers["asked"] == 0 + + +class TestThePromptEditor: + def test_unsaved_edits_are_asked_about(self, app, answers, tmp_path, monkeypatch): + from pybreeze.pybreeze_ui.extend_ai_gui import prompt_store + from pybreeze.pybreeze_ui.extend_ai_gui.prompt_edit_gui.cot_prompt_editor_widget import CoTPromptEditor + + monkeypatch.setattr(prompt_store, "pybreeze_data_path", lambda: tmp_path) + editor = CoTPromptEditor() + assert editor.may_close() is True + + editor.middle_editor.document().setModified(True) + + assert editor.may_close() is False + assert answers["asked"] == 1 + + +class TestTheMainWindowsQuestion: + def test_a_widget_without_the_question_may_close(self): + from pybreeze.pybreeze_ui.closing import may_close + + assert may_close(object()) is True + assert may_close(None) is True + + def test_a_widget_whose_question_raises_does_not_keep_the_ide_open(self): + from pybreeze.pybreeze_ui.closing import may_close + + class Broken: + @staticmethod + def may_close() -> bool: + raise RuntimeError("third-party tab") + + assert may_close(Broken()) is True + + def test_a_no_is_a_no(self): + from pybreeze.pybreeze_ui.closing import may_close + + class Keeps: + @staticmethod + def may_close() -> bool: + return False + + assert may_close(Keeps()) is False + + +class TestADock: + """A docked editor closed from its dock's own button, without a word.""" + + class _Keeps: + """Stands in for an editor with unsaved work the user keeps.""" + + def __init__(self, answer: bool) -> None: + from PySide6.QtWidgets import QWidget + + self.widget = QWidget() + self.widget.may_close = lambda: answer + + def test_a_dock_whose_widget_says_no_stays_open(self, app): + from pybreeze.pybreeze_ui.closing import AskingDock + + dock = AskingDock() + dock.setWidget(self._Keeps(False).widget) + dock.show() + + assert dock.close() is False + assert dock.isVisible() + dock.already_asked = True + assert dock.close() is True + + def test_a_dock_whose_widget_agrees_closes(self, app): + from pybreeze.pybreeze_ui.closing import AskingDock + + dock = AskingDock() + dock.setWidget(self._Keeps(True).widget) + dock.show() + + assert dock.close() is True + + def test_the_dock_menu_builds_docks_that_ask(self, app): + import inspect + + from pybreeze.pybreeze_ui.menu.tools import tools_menu + + assert "AskingDock()" in inspect.getsource(tools_menu.add_dock) + diff --git a/test/test_utils/test_url_builder_gui.py b/test/test_utils/test_url_builder_gui.py index 1a5c63ac..90ef9d0a 100644 --- a/test/test_utils/test_url_builder_gui.py +++ b/test/test_utils/test_url_builder_gui.py @@ -84,3 +84,17 @@ def test_initial_url_is_kept_in_the_input(self, app): def test_without_initial_url_the_output_stays_empty(self, widget): assert widget.output_edit.toPlainText() == "" + + +class TestAUrlThatCannotBeRead: + def test_it_is_reported_in_the_output_not_raised(self, app): + from pybreeze.pybreeze_ui.tools_gui.url_builder_gui import UrlBuilderGUI + + gui = UrlBuilderGUI() + gui.input_edit.setPlainText("http://[::1") + + gui.convert_to_json() + + assert gui.output_edit.toPlainText() + assert gui._valid_output is False + gui.deleteLater() diff --git a/test/test_utils/test_url_convert.py b/test/test_utils/test_url_convert.py index 2f797302..93f82091 100644 --- a/test/test_utils/test_url_convert.py +++ b/test/test_utils/test_url_convert.py @@ -36,8 +36,8 @@ def test_minimal_url(self): assert "username" not in parts def test_query_values_url_decoded(self): - parts = parse_url("https://x/?q=hello%20world") - assert parts["query"]["q"] == "hello world" + parts = parse_url("https://x/?q=hello+world&t=a%2Bb") + assert parts["query"] == {"q": "hello world", "t": "a+b"} def test_whitespace_is_stripped(self): assert parse_url(" https://x/api ")["path"] == "/api" @@ -85,6 +85,25 @@ class TestRoundTrip: def test_parse_then_build_is_stable(self, url): assert build_url(parse_url(url)) == url + @pytest.mark.parametrize(("part", "value", "written"), [ + ("fragment", "\r", "http://h/p#%0D"), + ("fragment", "a
b", "http://h/p#a%E2%80%A9b"), + ("fragment", "﷐", "http://h/p#%EF%B7%90"), + ("path", "/a b\n", "http://h/a%20b%0A"), + ("fragment", "中文", "http://h/p#中文"), + ("fragment", "x%20y", "http://h/p#x%20y"), + ]) + def test_what_a_text_box_would_change_is_percent_encoded(self, part, value, written): + # The output box gave "#\r" back as "#\n": Copy and Save wrote another URL + components = {"scheme": "http", "host": "h", "path": "/p", part: value} + + assert build_url(components) == written + + @pytest.mark.parametrize("url", ["http:////x", "http:////x/y?a=1#f", "////x", "file:////srv/share"]) + def test_a_path_starting_with_two_slashes_does_not_become_the_host(self, url): + # http:////x came back as http://x: the path's "x" was now the host + assert build_url(parse_url(url)) == url + class TestJsonToUrl: def test_builds_from_json(self): @@ -97,3 +116,129 @@ def test_invalid_json_raises(self): def test_non_object_raises(self): with pytest.raises(UrlConvertException): json_to_url('["a", "b"]') + + +class TestAUrlThatCannotBeSplit: + def test_an_unclosed_ipv6_bracket_is_a_convert_error(self): + from pybreeze.utils.exception.exceptions import UrlConvertException + from pybreeze.utils.url_tools.url_convert import url_to_json + + with pytest.raises(UrlConvertException): + url_to_json("http://[::1") + + +class TestARepeatedKey: + def test_every_value_is_kept(self): + from pybreeze.utils.url_tools.url_convert import parse_url + + assert parse_url("https://x/?tag=a&tag=b")["query"] == {"tag": ["a", "b"]} + + def test_the_round_trip_keeps_them(self): + from pybreeze.utils.url_tools.url_convert import build_url, parse_url + + assert build_url(parse_url("https://x/?tag=a&tag=b&one=1")) == "https://x/?tag=a&tag=b&one=1" + + def test_a_key_that_repeats_after_another_keeps_its_place(self): + # Grouped into a dict, a=1&b=2&a=3 came back as a=1&a=3&b=2: a signed URL breaks + parts = parse_url("https://x/?a=1&b=2&a=3") + + assert parts["query"] == "a=1&b=2&a=3" + assert build_url(parts) == "https://x/?a=1&b=2&a=3" + + +class TestAPortOfOtherDigits: + @pytest.mark.parametrize("port", ["²", "٣"]) + def test_it_is_refused(self, port): + # "²".isdigit() holds: int() raised past the builder, and "٣" became port 3 + with pytest.raises(UrlConvertException): + json_to_url(json.dumps({"scheme": "http", "host": "h", "port": port})) + + +class TestAPortOutOfRange: + def test_it_is_reported_rather_than_dropped(self): + from pybreeze.utils.url_tools.url_convert import url_to_json + + with pytest.raises(UrlConvertException): + url_to_json("http://host:99999/") + + @pytest.mark.parametrize("url", [ + "http://host/", "http://host:8080/", "http://[::1]:8080/", "http://[::1]/", + "http://user:pa:ss@host/"]) + def test_urls_without_a_bad_port_still_convert(self, url): + from pybreeze.utils.url_tools.url_convert import url_to_json + + assert json.loads(url_to_json(url))["scheme"] == "http" + + +class TestAnEmptyPort: + def test_the_colon_alone_names_no_port(self): + # RFC 3986 allows it; it was refused as a port out of range + assert json.loads(url_to_json("http://example.com:/p"))["port"] is None + + +class TestPartsThatAreNotText: + def test_null_parts_are_empty_not_none(self): + url = json_to_url('{"scheme": "http", "host": "h", "path": null, "fragment": null}') + + assert url == "http://h" + + def test_a_null_query_value_is_empty_as_in_the_query_tool(self): + assert json_to_url('{"scheme": "http", "host": "h", "query": {"a": null, "b": true}}') == "http://h?a=&b=true" + + def test_an_object_as_a_query_value_is_refused_not_flattened(self): + # {"x": 1} went into the URL as its keys: b=x + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "query": {"b": {"x": 1}}}') + + @pytest.mark.parametrize("port", ['"abc"', "70000", "-1", "true", "8.5"]) + def test_a_port_that_is_no_port_is_refused(self, port): + # "abc" went into the URL as h:abc + with pytest.raises(UrlConvertException): + json_to_url(f'{{"scheme": "http", "host": "h", "port": {port}}}') + + @pytest.mark.parametrize(("port", "expected"), [("8080", "http://h:8080"), ('"8080"', "http://h:8080"), + ("null", "http://h"), ('""', "http://h")]) + def test_a_port_given_as_text_or_left_out_still_builds(self, port, expected): + assert json_to_url(f'{{"scheme": "http", "host": "h", "port": {port}}}') == expected + + +class TestJsonNestedTooDeep: + def test_it_is_reported_not_raised_out_of_the_tab(self): + # json.loads raises RecursionError, which the tab did not catch + with pytest.raises(UrlConvertException): + json_to_url("[" * 100000) + + +class TestAQueryThatWouldNotComeBackAsWritten: + @pytest.mark.parametrize("url", [ + "http://h/p?%zz=1&x=%ff", "http://h/p?flag", "http://h/p?a=b/c;d", "https://h/p?q=a%20b", + ]) + def test_it_is_kept_as_text_and_rebuilt_unchanged(self, url): + # Decoded and encoded again, %zz became %25zz, %ff U+FFFD, flag flag=, / %2F + parts = json.loads(url_to_json(url)) + + assert isinstance(parts["query"], str) + assert json_to_url(url_to_json(url)) == url + + def test_one_that_does_is_still_a_dict_to_edit(self): + assert json.loads(url_to_json("https://h/p?a=1&b=x+y"))["query"] == {"a": "1", "b": "x y"} + + +class TestQueryValuesAsWritten: + def test_a_number_goes_into_the_url_as_written(self): + url = json_to_url('{"scheme": "http", "host": "h", "query": {"v": 1E3, "p": 1.10}}') + + assert url == "http://h?v=1E3&p=1.10" + + def test_a_port_is_still_a_number(self): + assert json_to_url('{"scheme": "http", "host": "h", "port": 8080}') == "http://h:8080" + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "port": 8080.5}') + + def test_nan_is_refused(self): + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "query": {"x": NaN}}') + + def test_a_lone_surrogate_is_refused_not_raised(self): + with pytest.raises(UrlConvertException): + json_to_url('{"scheme": "http", "host": "h", "query": {"q": "\ud83d"}}') diff --git a/test/test_utils/test_url_parser_differential.py b/test/test_utils/test_url_parser_differential.py new file mode 100644 index 00000000..80e0307a --- /dev/null +++ b/test/test_utils/test_url_parser_differential.py @@ -0,0 +1,88 @@ +"""The host validate_url checks is the host requests connects to. + +The check read the host with ``urlparse``; ``requests`` connects to the host +``urllib3`` reads. A URL the two read differently got a public host checked +and a private one connected to. +""" +from __future__ import annotations + +import socket +import threading + +import pytest +import requests + +from pybreeze.utils.network import url_validation +from pybreeze.utils.network.url_validation import UnsafeURLError, validate_url + +_PUBLIC = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.215.14", 0))] + + +@pytest.fixture() +def public_dns(monkeypatch): + """Every name resolves to a public address, so only the parsing decides.""" + monkeypatch.setattr(url_validation.socket, "getaddrinfo", lambda *_a, **_k: _PUBLIC) + + +@pytest.mark.parametrize("url", [ + "http://127.0.0.1\\@example.com/", + "http://127.0.0.1:8080\\@example.com/", + "http://169.254.169.254\\@example.com/latest/meta-data/", + "http://exam\nple.com/", + "http://example.com\t/", + "http://example.com/\x00", +]) +def test_a_url_read_two_ways_is_refused(public_dns, url): + with pytest.raises(UnsafeURLError): + validate_url(url) + + +@pytest.mark.parametrize("url", [ + "https://example.com/path?q=1#frag", + "https://user:secret@example.com/", + "https://EXAMPLE.com/", + "https://bücher.example/", + "https://api.example.com:8443/v1", +]) +def test_an_ordinary_url_still_passes(public_dns, url): + assert validate_url(url) == url + + +def test_the_parser_error_does_not_quote_the_url(public_dns): + with pytest.raises(UnsafeURLError) as raised: + validate_url("http://[::1/api?token=sk-live-not-a-real-key") + + assert "sk-live" not in str(raised.value) + + +def test_the_loopback_listener_is_never_reached(public_dns): + """The reported attack, end to end: the check passed and requests got the secret.""" + served: list = [] + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(3) + port = listener.getsockname()[1] + url = f"http://127.0.0.1:{port}\\@example.com/" + + def serve() -> None: + try: + connection, _address = listener.accept() + except OSError: + return + with connection: + served.append(connection.recv(1024)) + connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nSECRET") + + server = threading.Thread(target=serve) + server.start() + try: + validate_url(url) + requests.get(url, timeout=(3, 3), allow_redirects=False) + except UnsafeURLError: + pass + finally: + listener.close() + server.join(5) + + assert served == [] diff --git a/test/test_utils/test_url_validation.py b/test/test_utils/test_url_validation.py index 3cf3a6b1..c2b170d4 100644 --- a/test/test_utils/test_url_validation.py +++ b/test/test_utils/test_url_validation.py @@ -33,6 +33,7 @@ class TestPrivateAddressBlocking: "http://224.0.0.1/", # multicast "http://[::1]/", # IPv6 loopback "http://[fe80::1]/", # IPv6 link-local + "http://[fec0::1]/", # IPv6 site-local (deprecated, still routed in places) ]) def test_blocked(self, url): with pytest.raises(UnsafeURLError): @@ -60,3 +61,21 @@ class TestPublicAddressAllowed: ]) def test_public_ip_allowed(self, url): assert validate_url(url) == url + + +class TestHostnamesThatCannotBeLookedUp: + """Whatever a hostname is, the answer is an UnsafeURLError, not a stray exception. + + A caller catches ``UnsafeURLError``; anything else escapes its handler and, + in a Qt slot, takes the application with it. + """ + + @pytest.mark.parametrize("url", [ + "http://" + "a" * 70 + ".example", + "http://.example", + "http://exa mple.example", + "http://" + "b" * 300, + ], ids=["label too long", "empty label", "space in hostname", "name too long"]) + def test_an_impossible_hostname_is_refused_like_any_other(self, url): + with pytest.raises(UnsafeURLError): + validate_url(url) diff --git a/test/test_utils/test_utils_has_no_qt.py b/test/test_utils/test_utils_has_no_qt.py new file mode 100644 index 00000000..b4976fc2 --- /dev/null +++ b/test/test_utils/test_utils_has_no_qt.py @@ -0,0 +1,30 @@ +"""`pybreeze/utils/` is pure logic: nothing in it imports Qt or JEditor.""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pybreeze.utils + +_UTILS = Path(pybreeze.utils.__file__).parent +_UI_PACKAGES = ("PySide6", "je_editor") + + +def _imported_roots(source: Path) -> set[str]: + roots: set[str] = set() + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + roots.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + roots.add(node.module.split(".")[0]) + return roots + + +def test_no_module_in_utils_imports_a_ui_package(): + offenders = [ + str(source.relative_to(_UTILS)) for source in sorted(_UTILS.rglob("*.py")) + if _imported_roots(source) & set(_UI_PACKAGES) + ] + + # A folder dialog lived here once; dialogs belong with their callers. + assert offenders == [] diff --git a/test/test_utils/test_view_safe.py b/test/test_utils/test_view_safe.py new file mode 100644 index 00000000..b7bd7257 --- /dev/null +++ b/test/test_utils/test_view_safe.py @@ -0,0 +1,74 @@ +"""JSON a tool shows comes back from its text box as the same JSON.""" +from __future__ import annotations + +import json +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QPlainTextEdit # noqa: E402 — the platform must be set first + +from pybreeze.pybreeze_ui.exact_text import exact_text # noqa: E402 +from pybreeze.utils.json_format.view_safe import dumps_for_view, escape_for_view # noqa: E402 + +# What a Qt text view does not give back: U+2029, U+FDD0 and U+FDD1 come back +# as newlines, a lone surrogate not at all; U+2028 turns into a newline in +# toPlainText(), which JEditor saves and runs a tab through; U+0085 is a line +# break to str.splitlines() +NOT_KEPT = "a" + "".join(chr(code) for code in (0x85, 0x2028, 0x2029, 0xFDD0, 0xFDD1, 0xD83D)) + "b" + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def _through_a_view(text: str) -> str: + view = QPlainTextEdit() + view.setPlainText(text) + return exact_text(view) + + +def test_every_escaped_character_is_one_a_view_does_not_keep(app): + # So the list holds nothing a view would have kept anyway; U+0085, which + # Qt keeps, is escaped as the line break str.splitlines() takes it for + for character in NOT_KEPT[1:-1].replace(chr(0x85), ""): + view = QPlainTextEdit() + view.setPlainText(f"x{character}y") + assert (exact_text(view), view.toPlainText()) != (f"x{character}y",) * 2, hex(ord(character)) + + +def test_escaped_json_survives_a_view_and_means_the_same(app): + shown = dumps_for_view({"k": NOT_KEPT}, indent=4) + + assert json.loads(_through_a_view(shown)) == {"k": NOT_KEPT} + + +def test_other_text_is_left_as_it_is(): + assert escape_for_view('{"k": "café \U0001f600  "}') == '{"k": "café \U0001f600  "}' + + +def test_as_a_python_string_literal_it_means_the_same(): + assert eval(dumps_for_view(NOT_KEPT)) == NOT_KEPT # noqa: S307 — the literal this test just wrote + + +@pytest.mark.parametrize("convert", ["reformat", "minify", "query", "url"]) +def test_each_json_tool_output_survives_its_view(app, convert): + from pybreeze.utils.json_format.json_process import minify_json, reformat_json + from pybreeze.utils.query_tools.query_convert import query_to_json + from pybreeze.utils.url_tools.url_convert import url_to_json + + text = NOT_KEPT.replace(chr(0xD83D), "") + escaped = json.dumps({"k": text}) + output = { + "reformat": lambda: reformat_json(escaped), + "minify": lambda: minify_json(escaped), + "query": lambda: query_to_json("k=" + "".join(f"%{byte:02X}" for byte in text.encode("utf-8"))), + # The fragment is shown as it was written + "url": lambda: url_to_json("http://h/p#" + text), + }[convert]() + + loaded = json.loads(_through_a_view(output)) + assert (loaded["fragment"] if convert == "url" else loaded["k"]) == text