diff --git a/README.md b/README.md index 4a0cf68..44a958e 100644 --- a/README.md +++ b/README.md @@ -76,18 +76,19 @@ PyBreeze is not just a code editor — it is a command center for the automation - Mermaid `flowchart` / `graph` import - Save/Open as `.diagram.json`, export to PNG or SVG - Undo/redo, align, distribute, grid, snap, and zoom controls -- **cURL Import** — Paste a `curl` command copied from your browser's dev tools and generate a ready-to-run script for the target of your choice: **Python `requests`**, an **APITestka Python** snippet (`test_api_method_requests(...)`), an **APITestka JSON action** (`[["AT_test_api_method", {...}]]`) that `execute_files` runs directly, or a **LoadDensity** Locust load-test (`start_test(...)`). (These are the HTTP-oriented modules; a curl request has no meaningful mapping to the browser or desktop-GUI automation modules.) Parses the method, URL, headers, body, basic auth, `-G` query parameters, `-F` multipart form fields (file uploads become `files=open(...)`), the `--json` shortcut (which also sets the JSON `Content-Type`/`Accept`), and `-d @file` bodies (which become `open(...).read()`, while `--data-raw` stays literal) — all with multi-line `\` / `^` continuations. It knows the arity of curl's common flags, so value-taking options like `--max-time 30` or `-o out.json` never leak into the URL; it URL-encodes `--data-urlencode` values the way curl does; and it infers `POST` when a body or form is present and sends JSON bodies as JSON. Targets include Python `requests`, a ready-to-run **pytest test** (a named `test_...` function that sends the request and asserts the status), APITestka (Python and JSON action), and a LoadDensity load test. Pick one from the dropdown, then copy the result, **open it straight into a new editor tab**, or **save it to a `.py` / `.json` file** (the extension follows the chosen target). A repeated `-H` is handled the way HTTP handles it — the values are combined into one header (with `; ` for cookies) rather than the last one silently winning, and names are matched case-insensitively so an explicit header always beats one implied by another flag. One click also **hands the parsed URL to the URL parser/builder**, query string and all. The parser is pure logic and never executes the command. +- **cURL Import** — Paste a `curl` command copied from your browser's dev tools and generate a ready-to-run script for the target of your choice: **Python `requests`**, an **APITestka Python** snippet (`test_api_method_requests(...)`), an **APITestka JSON action** (`[["AT_test_api_method", {...}]]`) that `execute_files` runs directly, or a **LoadDensity** Locust load-test (`start_test(...)`). (These are the HTTP-oriented modules; a curl request has no meaningful mapping to the browser or desktop-GUI automation modules.) Parses the method, URL, headers, body, basic auth, `-G` query parameters, `-F` multipart form fields (file uploads become `files=open(...)`), the `--json` shortcut (which also sets the JSON `Content-Type`/`Accept`), and `-d @file` bodies (which become `open(...).read()`, while `--data-raw` stays literal) — all with multi-line `\` / `^` continuations. It knows the arity of curl's common flags, so value-taking options like `--max-time 30` or `-o out.json` never leak into the URL; it URL-encodes `--data-urlencode` values the way curl does; and it infers `POST` when a body or form is present and sends JSON bodies as JSON. Targets include Python `requests`, a ready-to-run **pytest test** (a named `test_...` function that sends the request and asserts the status), APITestka (Python and JSON action), and a LoadDensity load test. Pick one from the dropdown, then copy the result, **open it straight into a new editor tab**, or **save it to a `.py` / `.json` file** (the extension follows the chosen target). A repeated `-H` is handled the way HTTP handles it — the values are combined into one header (with `; ` for cookies) rather than the last one silently winning, and names are matched case-insensitively so an explicit header always beats one implied by another flag. One click also hands the parsed parts to the tool that specialises in them: the **URL to the URL parser/builder** (query string and all) or the **headers to the header analyzer**. The parser is pure logic and never executes the command. +- **HAR Import** — "Copy as cURL" captures one request; **Save all as HAR** captures the session. Open that `.har` export and every recorded call is listed with its method, path, status and media type — page furniture (stylesheets, images, fonts) filtered out by default so what remains is the API traffic. Select the calls you want, or take everything listed, and generate one script for the whole set using the same targets as the cURL importer: a **pytest file** with one test per call (repeated endpoints get numbered names so no test silently replaces another), an **APITestka action list** that replays the flow in capture order, a **`requests` walkthrough**, an APITestka Python script, or LoadDensity runs. Recorded headers, cookies, query parameters and bodies (raw, URL-encoded and multipart, including file uploads) all come across; HTTP/2 pseudo-headers are dropped and a `Cookie` header that duplicates the recorded cookie list is removed, so the generated code sends each value once. A single selected request produces exactly what the cURL importer would, so the two tools never disagree. HAR is JSON, so this needs nothing beyond the standard library, and nothing is ever replayed for you. - **JWT Decoder** — Paste a JSON Web Token and read its header and payload as pretty-printed JSON, with standard timestamp claims (`exp`, `iat`, `nbf`, `auth_time`) rendered as readable UTC times. Inspection only — the signature is never verified and the token is never trusted. - **Timestamp Converter** — Enter a Unix epoch value (seconds or milliseconds, auto-detected) or an ISO-8601 date-time (with `Z` or an offset) and get every representation back in UTC. Deterministic and independent of the local time zone. - **Hash Generator** — Compute SHA-256, SHA-512, SHA-1 and MD5 digests of any text at once. A general-purpose checksum tool (MD5/SHA-1 are provided for interoperability with `usedforsecurity=False`, never for security decisions). - **Query ⇄ JSON** — Convert an `application/x-www-form-urlencoded` query string to pretty JSON and back, URL-decoding and -encoding as needed. Repeated keys become JSON arrays and vice versa. Copy the result, open it in an editor tab, or save it to a file. - **URL Parser / Builder** — Paste a URL to see its scheme, host, port, path, query, fragment and credentials as an editable JSON object, tweak any part, and turn it back into a URL. Round-trips both ways, brackets IPv6 literals, and re-encodes query parameters for you. The cURL importer can send a request's URL straight here. -- **HTTP Header Analyzer** — Paste a request or response header block (from dev tools, `curl -i`, or a proxy log) and read back every field plus what is worth knowing about it: names sent more than once, `Set-Cookie` entries missing `Secure` / `HttpOnly` / `SameSite`, a wildcard CORS policy (and the wildcard-plus-credentials combination browsers reject outright), an HSTS `max-age` too short to survive a restart, CSP `unsafe-inline` / `unsafe-eval`, product banners, deprecated headers, and — for responses — the security headers that are absent. Headers carrying credentials are reported by name only; their values are never copied into the report. The Response Inspector hands its headers here in one click. +- **HTTP Header Analyzer** — Paste a request or response header block (from dev tools, `curl -i`, or a proxy log) and read back every field plus what is worth knowing about it: names sent more than once, `Set-Cookie` entries missing `Secure` / `HttpOnly` / `SameSite`, a wildcard CORS policy (and the wildcard-plus-credentials combination browsers reject outright), an HSTS `max-age` too short to survive a restart, CSP `unsafe-inline` / `unsafe-eval`, product banners, deprecated headers, and — for responses — the security headers that are absent. Headers carrying credentials are reported by name only; their values are never copied into the report. The cURL importer and the Response Inspector both hand their headers here in one click, and a bearer token spotted in any header opens in the JWT decoder, already decoded. - **Regex Tester** — Try a regular expression against sample text with `IGNORECASE` / `MULTILINE` / `DOTALL` / `VERBOSE` flags, and see every match with its offsets, numbered groups and named groups. Invalid patterns report a friendly error instead of crashing. - **HTTP Status Reference** — Search the full HTTP status code table (sourced from the standard library, so it stays current) by code prefix or keyword, with the reason phrase, description and status class for each. - **Text Diff** — Compare two pieces of text (for example an expected vs. actual API response) side by side and get a unified diff plus a one-line summary of how many lines were added or removed. Copy the diff, open it in an editor tab, or save it to a file. - **JSON Format** — Pretty-print or minify a JSON payload, with a clear validation error when the input is not valid JSON. Copy the result, open it in a new editor tab, or save it to a file. -- **Response Inspector** — Paste a raw HTTP response and get a decoded summary in one step: the status code is looked up in the HTTP reference, headers are parsed, a JSON body is pretty-printed, and any JWTs anywhere in the text (for example an `Authorization: Bearer` header) are decoded with their timestamp claims shown in UTC. Ties the HTTP-status, JSON-format and JWT tools together, and can open a found status code, JWT, or the whole header block directly in its dedicated tool tab, pre-filled. +- **Response Inspector** — Paste a raw HTTP response and get a decoded summary in one step: the status code is looked up in the HTTP reference, headers are parsed, a JSON body is pretty-printed, and any JWTs anywhere in the text (for example an `Authorization: Bearer` header) are decoded with their timestamp claims shown in UTC. Ties the HTTP-status, JSON-format and JWT tools together, and can open a found status code, JWT, the whole header block, or a JSON body directly in its dedicated tool tab, pre-filled. - **File Tree Context Menu** — Right-click any file or folder in the project tree to create files/folders, rename, delete, copy absolute or relative paths, or reveal the item in your platform file manager. Renaming or deleting a file that is currently open in an editor tab keeps the tab in sync. - **Package Manager** — Install automation modules and build tools directly from the IDE menu without leaving the editor. - **Integrated Documentation** — Quick access to documentation and GitHub pages for each automation module directly from the menu bar. @@ -219,6 +220,7 @@ PyBreeze UI (PySide6) │ ├── Skill Send GUI │ ├── Diagram Editor (WYSIWYG, Mermaid import, PNG/SVG export) │ ├── cURL Import (curl → Python requests code) +│ ├── HAR Import (browser session → test suite) │ ├── JWT Decoder (header/payload inspection) │ ├── Timestamp Converter (epoch ⇄ ISO-8601) │ ├── Hash Generator (SHA-256/512, SHA-1, MD5) diff --git a/pybreeze/extend_multi_language/extend_english.py b/pybreeze/extend_multi_language/extend_english.py index 0a73690..379b699 100644 --- a/pybreeze/extend_multi_language/extend_english.py +++ b/pybreeze/extend_multi_language/extend_english.py @@ -332,6 +332,25 @@ "curl_import_target_apitestka_action": "APITestka (JSON action)", "curl_import_target_loaddensity_python": "LoadDensity (Python)", "curl_import_open_url_button": "Open URL in parser / builder", + "curl_import_open_headers_button": "Open headers in analyzer", + # HAR Import — Menu + "extend_tools_menu_har_import_tab_action": "HAR Import Tab", + "extend_tools_menu_har_import_tab_label": "HAR Import", + "extend_tools_menu_har_import_dock_action": "HAR Import Dock", + "extend_tools_menu_har_import_dock_title": "HAR Import", + # HAR Import — Widget + "har_import_open_button": "Open .har file...", + "har_import_file_dialog_title": "Open a HAR export", + "har_import_file_filter": "HAR export (*.har *.json)", + "har_import_api_only": "Only API-like requests", + "har_import_summary": "{total} request(s), {api} API-like — {hosts}", + "har_import_generate_selected": "Generate for selection", + "har_import_generate_all": "Generate for all listed", + "har_import_no_selection": "Select one or more requests, or generate for all listed.", + "har_import_empty_hint": + "Open a .har file saved from your browser's dev tools (Network → Save all as HAR).", + "har_import_error": "Could not read the HAR export: {error}", + "har_import_read_error": "Could not open the file: {error}", # Shared output actions (copy / open in editor / save to file) "output_actions_copy": "Copy", "output_actions_open_editor": "Open in editor tab", @@ -469,6 +488,7 @@ "response_open_jwt_button": "Open JWT in decoder", "response_open_status_button": "Open status in reference", "response_open_headers_button": "Open headers in analyzer", + "response_open_body_button": "Open body in JSON format", "response_empty_hint": "Paste a response above, then click analyze.", "response_status_label": "== Status ==", "response_headers_label": "== Headers ==", @@ -493,6 +513,7 @@ "header_analyzer_empty_hint": "Paste a header block above, then click analyze.", "header_analyzer_level_warning": "WARNING", "header_analyzer_level_info": "INFO", + "header_analyzer_open_jwt_button": "Open token in JWT decoder", # HTTP Header Analyzer — Findings ({header} is the header, {detail} its value) "header_finding_duplicate_header": "{header}: sent {detail} times; the receiver joins the values into one.", diff --git a/pybreeze/extend_multi_language/extend_traditional_chinese.py b/pybreeze/extend_multi_language/extend_traditional_chinese.py index 68981aa..550dc89 100644 --- a/pybreeze/extend_multi_language/extend_traditional_chinese.py +++ b/pybreeze/extend_multi_language/extend_traditional_chinese.py @@ -310,6 +310,25 @@ "curl_import_target_apitestka_action": "APITestka(JSON action)", "curl_import_target_loaddensity_python": "LoadDensity(Python)", "curl_import_open_url_button": "在 URL 解析/組建器開啟網址", + "curl_import_open_headers_button": "在分析器開啟標頭", + # HAR 匯入 — 選單 + "extend_tools_menu_har_import_tab_action": "HAR 匯入分頁", + "extend_tools_menu_har_import_tab_label": "HAR 匯入", + "extend_tools_menu_har_import_dock_action": "HAR 匯入停駐窗格", + "extend_tools_menu_har_import_dock_title": "HAR 匯入", + # HAR 匯入 — 介面 + "har_import_open_button": "開啟 .har 檔...", + "har_import_file_dialog_title": "開啟 HAR 匯出檔", + "har_import_file_filter": "HAR 匯出檔 (*.har *.json)", + "har_import_api_only": "只列出像 API 的請求", + "har_import_summary": "{total} 個請求,其中 {api} 個像 API — {hosts}", + "har_import_generate_selected": "為選取項產生", + "har_import_generate_all": "為列出的全部產生", + "har_import_no_selection": "請選取一個以上的請求,或改用「為列出的全部產生」。", + "har_import_empty_hint": + "請開啟從瀏覽器開發者工具存出的 .har 檔(Network → Save all as HAR)。", + "har_import_error": "無法讀取這份 HAR 匯出檔:{error}", + "har_import_read_error": "無法開啟檔案:{error}", # 共用輸出動作(複製 / 開成分頁 / 存檔) "output_actions_copy": "複製", "output_actions_open_editor": "開成編輯器分頁", @@ -447,6 +466,7 @@ "response_open_jwt_button": "在解碼器開啟 JWT", "response_open_status_button": "在參考開啟狀態碼", "response_open_headers_button": "在分析器開啟標頭", + "response_open_body_button": "在 JSON 格式化開啟內容", "response_empty_hint": "請先在上方貼上回應,再按分析。", "response_status_label": "== 狀態 Status ==", "response_headers_label": "== 標頭 Headers ==", @@ -471,6 +491,7 @@ "header_analyzer_empty_hint": "請先在上方貼上標頭,再按分析。", "header_analyzer_level_warning": "警告", "header_analyzer_level_info": "資訊", + "header_analyzer_open_jwt_button": "在 JWT 解碼器開啟權杖", # HTTP 標頭分析器 — 發現({header} 為標頭名稱,{detail} 為其值) "header_finding_duplicate_header": "{header}:送出 {detail} 次,接收端會把這些值合併成一個。", diff --git a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py index a3eed66..13eb14d 100644 --- a/pybreeze/pybreeze_ui/menu/tools/tools_menu.py +++ b/pybreeze/pybreeze_ui/menu/tools/tools_menu.py @@ -19,6 +19,7 @@ from pybreeze.pybreeze_ui.tools_gui.diff_gui import DiffGUI from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI +from pybreeze.pybreeze_ui.tools_gui.har_import_gui import HarImportGUI from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI @@ -114,6 +115,17 @@ def build_tools_menu(ui_we_want_to_set: PyBreezeMainWindow): )) ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_curl_import_action) + # HAR Import + ui_we_want_to_set.tools_har_import_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_har_import_tab_action" + )) + ui_we_want_to_set.tools_har_import_action.triggered.connect(lambda: ui_we_want_to_set.tab_widget.addTab( + HarImportGUI(ui_we_want_to_set), language_wrapper.language_word_dict.get( + "extend_tools_menu_har_import_tab_label" + ) + )) + ui_we_want_to_set.tools_menu.addAction(ui_we_want_to_set.tools_har_import_action) + # JWT Decoder ui_we_want_to_set.tools_jwt_decoder_action = QAction(language_wrapper.language_word_dict.get( "extend_tools_menu_jwt_decoder_tab_action" @@ -292,6 +304,13 @@ def extend_dock_menu(ui_we_want_to_set: PyBreezeMainWindow): lambda: add_dock(ui_we_want_to_set, "CurlImport")) ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_curl_import_dock_action) + # HAR Import Dock + ui_we_want_to_set.tools_har_import_dock_action = QAction(language_wrapper.language_word_dict.get( + "extend_tools_menu_har_import_dock_action")) + ui_we_want_to_set.tools_har_import_dock_action.triggered.connect( + lambda: add_dock(ui_we_want_to_set, "HarImport")) + ui_we_want_to_set.dock_menu.addAction(ui_we_want_to_set.tools_har_import_dock_action) + # JWT Decoder Dock ui_we_want_to_set.tools_jwt_decoder_dock_action = QAction(language_wrapper.language_word_dict.get( "extend_tools_menu_jwt_decoder_dock_action")) @@ -385,6 +404,7 @@ def extend_dock_menu(ui_we_want_to_set: PyBreezeMainWindow): "extend_tools_menu_diagram_editor_dock_title", lambda _win: DiagramEditorWidget()), "CurlImport": ( "extend_tools_menu_curl_import_dock_title", lambda win: CurlImportGUI(win)), + "HarImport": ("extend_tools_menu_har_import_dock_title", lambda win: HarImportGUI(win)), "JwtDecoder": ( "extend_tools_menu_jwt_decoder_dock_title", lambda win: JwtDecoderGUI(main_window=win)), "Timestamp": ("extend_tools_menu_timestamp_dock_title", lambda win: TimestampGUI(win)), diff --git a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py index 3ea371d..8a743bc 100644 --- a/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/curl_import_gui.py @@ -10,10 +10,11 @@ from __future__ import annotations from PySide6.QtWidgets import ( - QComboBox, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget + QComboBox, QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget ) from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab from pybreeze.pybreeze_ui.tools_gui.url_builder_gui import UrlBuilderGUI @@ -60,10 +61,18 @@ def __init__(self, main_window=None) -> None: self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) - # Cross-tool action: send the parsed URL to the URL parser/builder. + # Cross-tool actions: hand the parsed parts to the tool that specialises + # in them, rather than making the user copy them across. self.open_url_button = QPushButton(word.get("curl_import_open_url_button")) self.open_url_button.clicked.connect(self.open_url_in_builder) self.open_url_button.setEnabled(False) + self.open_headers_button = QPushButton(word.get("curl_import_open_headers_button")) + self.open_headers_button.clicked.connect(self.open_headers_in_analyzer) + self.open_headers_button.setEnabled(False) + + cross_tool = QHBoxLayout() + cross_tool.addWidget(self.open_url_button) + cross_tool.addWidget(self.open_headers_button) # Shared copy / open-in-editor / save actions. The extension and basename # follow the selected target; open/save are no-ops until a valid template. @@ -77,9 +86,10 @@ def __init__(self, main_window=None) -> None: for widget in ( self.input_label, self.input_edit, self.target_label, self.target_select, self.convert_button, - self.output_label, self.output_edit, self.open_url_button, + self.output_label, self.output_edit, ): layout.addWidget(widget) + layout.addLayout(cross_tool) layout.addLayout(self.actions.button_row()) self.setLayout(layout) @@ -97,6 +107,7 @@ def _clear_result(self) -> None: self._generated_code = None self._request = None self.open_url_button.setEnabled(False) + self.open_headers_button.setEnabled(False) def convert(self) -> None: """Parse the input command and show the template for the chosen target.""" @@ -118,6 +129,7 @@ def convert(self) -> None: self._generated_code = code self._request = request self.open_url_button.setEnabled(bool(request.url)) + self.open_headers_button.setEnabled(bool(request.headers)) self.output_edit.setPlainText(code) def open_url_in_builder(self) -> QWidget | None: @@ -132,3 +144,14 @@ def open_url_in_builder(self) -> QWidget | None: self._main_window, UrlBuilderGUI(main_window=self._main_window, initial_url=self._request.full_url), "extend_tools_menu_url_builder_tab_label") + + def open_headers_in_analyzer(self) -> QWidget | None: + """Open the command's headers in the header analyzer, already analysed.""" + if self._request is None or not self._request.headers: + return None + block = "\n".join( + f"{name}: {value}" for name, value in self._request.headers.items()) + return open_tool_tab( + self._main_window, + HeaderAnalyzerGUI(main_window=self._main_window, initial_headers=block), + "extend_tools_menu_header_analyzer_tab_label") diff --git a/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py new file mode 100644 index 0000000..bb9848c --- /dev/null +++ b/pybreeze/pybreeze_ui/tools_gui/har_import_gui.py @@ -0,0 +1,188 @@ +"""A tool tab that turns a browser HAR export into automation scripts. + +"Copy as cURL" captures one request; **Save all as HAR** captures the session. +This widget reads that export, lists the recorded requests (hiding page assets +unless asked), and generates a script for the selected ones — a pytest file of +several tests, an APITestka action list that replays the flow, and so on — using +the same targets and output actions as the cURL importer. +""" +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtWidgets import ( + QAbstractItemView, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, + QListWidget, QPushButton, QTextEdit, QVBoxLayout, QWidget +) +from je_editor import language_wrapper + +from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.utils.curl_import.script_templates import TEMPLATE_TARGETS +from pybreeze.utils.exception.exceptions import HarParseException +from pybreeze.utils.har_import.har_codegen import generate_har_script +from pybreeze.utils.har_import.har_parser import HarEntry, api_entries, parse_har, summarize +from pybreeze.utils.logging.logger import pybreeze_logger + +# The single target that generates JSON rather than Python +_JSON_TARGET = "apitestka_action" +# Separator between hosts in the summary line +_HOST_SEPARATOR = ", " +# Hosts listed before the summary is shortened +_MAX_LISTED_HOSTS = 3 + + +class HarImportGUI(QWidget): + """Open a ``.har`` export, pick recorded requests, and generate a script.""" + + def __init__(self, main_window=None) -> None: + """ + :param main_window: the window whose ``tab_widget`` "open in editor" uses + """ + super().__init__() + # Every request in the export, and the subset currently listed. + self._entries: list[HarEntry] = [] + self._shown: list[HarEntry] = [] + self._generated_code: str | None = None + word = language_wrapper.language_word_dict + + self.open_button = QPushButton(word.get("har_import_open_button")) + self.open_button.clicked.connect(self.open_file) + self.api_only_check = QCheckBox(word.get("har_import_api_only")) + self.api_only_check.setChecked(True) + self.api_only_check.stateChanged.connect(self._refresh_entry_list) + + top_row = QHBoxLayout() + top_row.addWidget(self.open_button) + top_row.addWidget(self.api_only_check) + + self.summary_label = QLabel(word.get("har_import_empty_hint")) + self.entry_list = QListWidget() + self.entry_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + + self.target_label = QLabel(word.get("curl_import_target_label")) + self.target_select = QComboBox() + for target_key, label_key in TEMPLATE_TARGETS: + self.target_select.addItem(word.get(label_key), target_key) + + self.generate_selected_button = QPushButton(word.get("har_import_generate_selected")) + self.generate_selected_button.clicked.connect(self.generate_selected) + self.generate_all_button = QPushButton(word.get("har_import_generate_all")) + self.generate_all_button.clicked.connect(self.generate_all) + + generate_row = QHBoxLayout() + generate_row.addWidget(self.generate_selected_button) + generate_row.addWidget(self.generate_all_button) + + self.output_label = QLabel(word.get("curl_import_output_label")) + self.output_edit = QTextEdit() + self.output_edit.setReadOnly(True) + + self.actions = OutputActions( + self, self.output_edit, main_window=main_window, + basename=lambda: "actions" if self.selected_target() == _JSON_TARGET else "session", + extension=lambda: "json" if self.selected_target() == _JSON_TARGET else "py", + is_valid=lambda: self._generated_code is not None) + + layout = QVBoxLayout() + layout.addLayout(top_row) + layout.addWidget(self.summary_label) + layout.addWidget(self.entry_list) + layout.addWidget(self.target_label) + layout.addWidget(self.target_select) + layout.addLayout(generate_row) + layout.addWidget(self.output_label) + layout.addWidget(self.output_edit) + layout.addLayout(self.actions.button_row()) + self.setLayout(layout) + + def selected_target(self) -> str: + """Return the template key of the currently selected target.""" + return self.target_select.currentData() + + def open_file(self) -> str | None: + """Ask for a ``.har`` file, then load it; return the path or ``None``.""" + word = language_wrapper.language_word_dict + path, _selected = QFileDialog.getOpenFileName( + self, word.get("har_import_file_dialog_title"), "", + word.get("har_import_file_filter")) + if not path: + return None + try: + text = Path(path).read_text(encoding="utf-8") + except OSError as error: + pybreeze_logger.info("har_import_gui.py read failed: %r", error) + self._report_error(word.get("har_import_read_error").format(error=str(error))) + return None + self.load_text(text) + return path + + def load_text(self, text: str) -> bool: + """Parse *text* as a HAR export and list what it recorded. + + :param text: the contents of a ``.har`` file + :return: whether the export parsed + """ + try: + entries = parse_har(text) + except HarParseException as error: + pybreeze_logger.info("har_import_gui.py parse failed: %r", error) + self._entries = [] + self._refresh_entry_list() + self._report_error( + language_wrapper.language_word_dict.get("har_import_error").format( + error=str(error))) + return False + self._entries = entries + self._refresh_entry_list() + return True + + def _report_error(self, message: str) -> None: + """Show *message* as the summary and clear any generated output.""" + self._generated_code = None + self.summary_label.setText(message) + self.output_edit.setPlainText(message) + + def _summary_text(self) -> str: + """Return the counts-and-hosts line describing the loaded export.""" + word = language_wrapper.language_word_dict + if not self._entries: + return word.get("har_import_empty_hint") + summary = summarize(self._entries) + hosts = _HOST_SEPARATOR.join(summary.hosts[:_MAX_LISTED_HOSTS]) + if len(summary.hosts) > _MAX_LISTED_HOSTS: + hosts = f"{hosts}…" + return word.get("har_import_summary").format( + total=summary.total, api=summary.api, hosts=hosts) + + def _refresh_entry_list(self) -> None: + """Rebuild the list from the current export and the API-only filter.""" + self._shown = api_entries(self._entries) if self.api_only_check.isChecked() \ + else list(self._entries) + self.entry_list.clear() + self.entry_list.addItems([entry.summary() for entry in self._shown]) + self.summary_label.setText(self._summary_text()) + + def selected_entries(self) -> list[HarEntry]: + """Return the listed entries the user selected, in capture order.""" + rows = sorted(index.row() for index in self.entry_list.selectedIndexes()) + return [self._shown[row] for row in rows if 0 <= row < len(self._shown)] + + def _generate(self, entries: list[HarEntry], empty_hint_key: str) -> None: + """Generate a script for *entries*, or show the hint when there are none.""" + word = language_wrapper.language_word_dict + if not entries: + self._generated_code = None + self.output_edit.setPlainText(word.get(empty_hint_key)) + return + code = generate_har_script( + self.selected_target(), [entry.request for entry in entries]) + self._generated_code = code + self.output_edit.setPlainText(code) + + def generate_selected(self) -> None: + """Generate a script covering the selected requests.""" + self._generate(self.selected_entries(), "har_import_no_selection") + + def generate_all(self) -> None: + """Generate a script covering every request currently listed.""" + self._generate(self._shown, "har_import_empty_hint") diff --git a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py index 960fb3e..0fdd492 100644 --- a/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/header_analyzer_gui.py @@ -10,10 +10,13 @@ from PySide6.QtWidgets import QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget from je_editor import language_wrapper +from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions +from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab from pybreeze.utils.header_tools.header_analyzer import ( HeaderAnalysis, HeaderFinding, analyze_headers ) +from pybreeze.utils.jwt_tools.jwt_decoder import find_tokens def _finding_line(finding: HeaderFinding) -> str: @@ -58,6 +61,7 @@ def __init__(self, main_window=None, initial_headers: str | None = None) -> None :param initial_headers: a header block to pre-fill and analyse on open """ super().__init__() + self._main_window = main_window self._analysis: HeaderAnalysis | None = None word = language_wrapper.language_word_dict @@ -73,6 +77,12 @@ def __init__(self, main_window=None, initial_headers: str | None = None) -> None self.output_edit = QTextEdit() self.output_edit.setReadOnly(True) + # Cross-tool action: a bearer token in a header is worth reading, and the + # decoder is the tool that reads it. + self.open_jwt_button = QPushButton(word.get("header_analyzer_open_jwt_button")) + self.open_jwt_button.clicked.connect(self.open_jwt_in_decoder) + self.open_jwt_button.setEnabled(False) + self.actions = OutputActions( self, self.output_edit, main_window=main_window, basename="headers", extension="txt", @@ -81,7 +91,7 @@ def __init__(self, main_window=None, initial_headers: str | None = None) -> None layout = QVBoxLayout() for widget in ( self.input_label, self.input_edit, self.analyze_button, - self.output_label, self.output_edit, + self.output_label, self.output_edit, self.open_jwt_button, ): layout.addWidget(widget) layout.addLayout(self.actions.button_row()) @@ -91,18 +101,42 @@ def __init__(self, main_window=None, initial_headers: str | None = None) -> None self.input_edit.setPlainText(initial_headers) self.analyze() + def _clear_analysis(self, message: str) -> None: + """Forget the analysis and show *message* instead of a report.""" + self._analysis = None + self.open_jwt_button.setEnabled(False) + self.output_edit.setPlainText(message) + def analyze(self) -> None: """Analyse the pasted headers and show the report.""" word = language_wrapper.language_word_dict text = self.input_edit.toPlainText().strip() if not text: - self._analysis = None - self.output_edit.setPlainText(word.get("header_analyzer_empty_hint")) + self._clear_analysis(word.get("header_analyzer_empty_hint")) return analysis = analyze_headers(text) if not analysis.fields: - self._analysis = None - self.output_edit.setPlainText(word.get("header_analyzer_no_headers")) + self._clear_analysis(word.get("header_analyzer_no_headers")) return self._analysis = analysis + self.open_jwt_button.setEnabled(bool(self.header_tokens())) self.output_edit.setPlainText(build_header_report(analysis)) + + def header_tokens(self) -> list[str]: + """Return the JWT-looking tokens carried by the analysed headers.""" + if self._analysis is None: + return [] + tokens: list[str] = [] + for header in self._analysis.fields: + tokens.extend(token for token in find_tokens(header.value) if token not in tokens) + return tokens + + def open_jwt_in_decoder(self) -> QWidget | None: + """Open the first token found in a header in the JWT decoder, decoded.""" + tokens = self.header_tokens() + if not tokens: + return None + return open_tool_tab( + self._main_window, + JwtDecoderGUI(initial_token=tokens[0], main_window=self._main_window), + "extend_tools_menu_jwt_decoder_tab_label") diff --git a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py index 49f2fbc..8dc164d 100644 --- a/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/json_format_gui.py @@ -15,9 +15,10 @@ class JsonFormatGUI(QWidget): """Paste JSON, then format it, minify it, or read its validation error.""" - def __init__(self, main_window=None) -> None: + def __init__(self, main_window=None, initial_json: str | None = None) -> None: """ :param main_window: window whose ``tab_widget`` "open in editor" uses + :param initial_json: JSON to pre-fill and format on open, if given """ super().__init__() # The last valid result (not an error/hint), gating open/save. @@ -56,6 +57,10 @@ def __init__(self, main_window=None) -> None: layout.addLayout(self.actions.button_row()) self.setLayout(layout) + if initial_json: + self.input_edit.setPlainText(initial_json) + self.format_json() + def _run(self, transform) -> None: """Apply a JSON transform, showing the result or a friendly error.""" word = language_wrapper.language_word_dict diff --git a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py index 7e97b84..85fabf1 100644 --- a/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py +++ b/pybreeze/pybreeze_ui/tools_gui/response_inspector_gui.py @@ -14,6 +14,7 @@ from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI +from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI from pybreeze.pybreeze_ui.tools_gui.output_actions import OutputActions from pybreeze.pybreeze_ui.tools_gui.tool_tabs import open_tool_tab @@ -105,6 +106,9 @@ def __init__(self, main_window=None) -> None: self.open_headers_button = QPushButton(word.get("response_open_headers_button")) self.open_headers_button.clicked.connect(self.open_headers_in_analyzer) self.open_headers_button.setEnabled(False) + self.open_body_button = QPushButton(word.get("response_open_body_button")) + self.open_body_button.clicked.connect(self.open_body_in_json_format) + self.open_body_button.setEnabled(False) # Shared copy / open-in-editor / save actions, valid once analysed. self.actions = OutputActions( @@ -115,6 +119,7 @@ def __init__(self, main_window=None) -> None: cross_tool = QHBoxLayout() cross_tool.addWidget(self.open_status_button) cross_tool.addWidget(self.open_headers_button) + cross_tool.addWidget(self.open_body_button) cross_tool.addWidget(self.open_jwt_button) layout = QVBoxLayout() @@ -133,16 +138,24 @@ def analyze(self) -> None: text = self.input_edit.toPlainText().strip() if not text: self._analysis = None - self.open_jwt_button.setEnabled(False) - self.open_status_button.setEnabled(False) - self.open_headers_button.setEnabled(False) + self._set_cross_tool_enabled(jwt=False, status=False, headers=False, body=False) self.output_edit.setPlainText(word.get("response_empty_hint")) return self._analysis = analyze_response(text) self.output_edit.setPlainText(build_report_text(self._analysis)) - self.open_jwt_button.setEnabled(bool(self._analysis.jwt_findings)) - self.open_status_button.setEnabled(self._analysis.status is not None) - self.open_headers_button.setEnabled(bool(self._analysis.headers)) + self._set_cross_tool_enabled( + jwt=bool(self._analysis.jwt_findings), + status=self._analysis.status is not None, + headers=bool(self._analysis.headers), + body=self._analysis.is_json_body) + + def _set_cross_tool_enabled( + self, *, jwt: bool, status: bool, headers: bool, body: bool) -> None: + """Enable each hand-off only when the analysis has something to hand over.""" + self.open_jwt_button.setEnabled(jwt) + self.open_status_button.setEnabled(status) + self.open_headers_button.setEnabled(headers) + self.open_body_button.setEnabled(body) def open_jwt_in_decoder(self) -> QWidget | None: """Open the first found JWT in the JWT decoder tool, pre-filled.""" @@ -179,3 +192,12 @@ def open_headers_in_analyzer(self) -> QWidget | None: main_window=self._main_window, initial_headers=self.input_edit.toPlainText()), "extend_tools_menu_header_analyzer_tab_label") + + def open_body_in_json_format(self) -> QWidget | None: + """Open a JSON body in the JSON format tool, already pretty-printed.""" + if self._analysis is None or not self._analysis.is_json_body: + return None + return open_tool_tab( + self._main_window, + JsonFormatGUI(main_window=self._main_window, initial_json=self._analysis.body), + "extend_tools_menu_json_format_tab_label") diff --git a/pybreeze/utils/curl_import/curl_parser.py b/pybreeze/utils/curl_import/curl_parser.py index 1764a54..5c74a28 100644 --- a/pybreeze/utils/curl_import/curl_parser.py +++ b/pybreeze/utils/curl_import/curl_parser.py @@ -20,19 +20,15 @@ not_a_curl_command_error, ) from pybreeze.utils.exception.exceptions import CurlParseException +from pybreeze.utils.header_tools.header_merge import ( + add_header, set_default_header, stored_header_name +) from pybreeze.utils.logging.logger import pybreeze_logger # HTTP method used when none is given and no body is present _DEFAULT_METHOD = "GET" # HTTP method implied when a body is present but no method is given _METHOD_WITH_BODY = "POST" -# Header name that carries a request body's media type -_CONTENT_TYPE_HEADER = "content-type" -# Header whose repeated values are joined with "; " instead of ", " -_COOKIE_HEADER = "cookie" -# How repeated header lines are combined into one value (RFC 9110 field order) -_HEADER_JOINER = ", " -_COOKIE_JOINER = "; " # Matches a backslash or caret line continuation before a newline _LINE_CONTINUATION_RE = re.compile(r"[\\^]\r?\n") @@ -94,7 +90,7 @@ def full_url(self) -> str: def header_value(self, name: str) -> str | None: """Return a header's value by case-insensitive *name*, or ``None``.""" - stored = _stored_header_name(self, name) + stored = stored_header_name(self.headers, name) return None if stored is None else self.headers[stored] @@ -217,53 +213,19 @@ def _tokenize(command: str) -> list[str]: raise CurlParseException(malformed_curl_command_error) from error -def _stored_header_name(request: CurlRequest, name: str) -> str | None: - """Return the already-stored spelling of *name*, matched case-insensitively.""" - lowered = name.lower() - for stored in request.headers: - if stored.lower() == lowered: - return stored - return None - - -def _set_default_header(request: CurlRequest, name: str, value: str) -> None: - """Set a header only when no header of that name is present. - - Case-insensitive, so an explicit ``-H 'content-type: ...'`` still wins over a - default implied by another flag instead of producing a second header line. - """ - if _stored_header_name(request, name) is None: - request.headers[name] = value - - -def _join_header_values(name: str, previous: str, value: str) -> str: - """Combine a repeated header's values the way HTTP combines field lines.""" - if not previous: - return value - if not value: - return previous - joiner = _COOKIE_JOINER if name.lower() == _COOKIE_HEADER else _HEADER_JOINER - return joiner.join((previous, value)) - - def _apply_header(request: CurlRequest, raw_header: str) -> None: """Record a ``Name: Value`` header, combining repeats of the same name. curl sends every ``-H`` it is given, so a repeated name is not a mistake: the - receiver combines those field lines into one comma-separated value (cookies - use ``; ``), which is what the generated code should carry. Names are matched - case-insensitively, as HTTP header names are. Fragments with no colon or an - empty name are ignored. + receiver combines those field lines into one value, which is what the + generated code should carry. Fragments with no colon or an empty name are + ignored. """ name, separator, value = raw_header.partition(":") name, value = name.strip(), value.strip() if not separator or not name: return - stored = _stored_header_name(request, name) - if stored is None: - request.headers[name] = value - return - request.headers[stored] = _join_header_values(stored, request.headers[stored], value) + add_header(request.headers, name, value) def _urlencode_data_part(value: str) -> str: @@ -308,7 +270,7 @@ def _apply_cookie(request: CurlRequest, value: str) -> None: cookie *file* curl would read, which we keep as a ``Cookie`` header instead. """ if "=" not in value: - _set_default_header(request, "Cookie", value) + set_default_header(request.headers, "Cookie", value) return for segment in value.split(";"): name, separator, cookie_value = segment.strip().partition("=") @@ -331,16 +293,16 @@ def _apply_value_flag(request: CurlRequest, kind: str, value: str) -> None: elif kind == "json_flag": # curl --json is shorthand for --data + JSON Content-Type and Accept. request.data_parts.append(value) - _set_default_header(request, "Content-Type", "application/json") - _set_default_header(request, "Accept", "application/json") + set_default_header(request.headers, "Content-Type", "application/json") + set_default_header(request.headers, "Accept", "application/json") elif kind == "form": request.form_fields.append(value) elif kind == "cookie": _apply_cookie(request, value) elif kind == "user_agent": - _set_default_header(request, "User-Agent", value) + set_default_header(request.headers, "User-Agent", value) elif kind == "referer": - _set_default_header(request, "Referer", value) + set_default_header(request.headers, "Referer", value) elif kind == "url": request.url = value elif kind == "timeout": diff --git a/pybreeze/utils/curl_import/request_codegen.py b/pybreeze/utils/curl_import/request_codegen.py index 28d706b..eeb92e0 100644 --- a/pybreeze/utils/curl_import/request_codegen.py +++ b/pybreeze/utils/curl_import/request_codegen.py @@ -13,6 +13,8 @@ # Keyword argument passing the request body to ``requests.request``. _DATA_KWARG = "data=data" +# Import line every generated ``requests`` script starts with +REQUESTS_IMPORT = "import requests" def _format_dict(name: str, mapping: dict[str, str]) -> str | None: @@ -145,7 +147,7 @@ def to_requests_code(request: CurlRequest) -> str: :param request: the parsed curl request :return: Python source using the ``requests`` library """ - lines = ["import requests", ""] + lines = [REQUESTS_IMPORT, ""] lines.extend(request_statements(request)) lines.append("print(response.status_code)") lines.append("print(response.text)") diff --git a/pybreeze/utils/curl_import/script_templates.py b/pybreeze/utils/curl_import/script_templates.py index 7b4ee88..fa43a0d 100644 --- a/pybreeze/utils/curl_import/script_templates.py +++ b/pybreeze/utils/curl_import/script_templates.py @@ -19,11 +19,14 @@ from pybreeze.utils.curl_import.curl_parser import CurlRequest from pybreeze.utils.curl_import.request_body import body_kind, form_parts from pybreeze.utils.curl_import.request_codegen import ( - data_from_file_expr, request_statements, to_requests_code + REQUESTS_IMPORT, data_from_file_expr, request_statements, to_requests_code ) # APITestka action command that performs an HTTP request _APITESTKA_ACTION = "AT_test_api_method" +# Import line each generated automation-module script starts with +APITESTKA_IMPORT = "from je_api_testka import test_api_method_requests" +LOADDENSITY_IMPORT = "from je_load_density import start_test" # Default status asserted in a generated pytest test _DEFAULT_EXPECTED_STATUS = 200 # Indentation for statements inside a generated test function @@ -54,15 +57,16 @@ def _apitestka_payload_lines(request: CurlRequest) -> list[str]: return [f" {kind[0]}={_inline_json(kind[1])},"] if kind is not None else [] -def to_apitestka_python(request: CurlRequest) -> str: - """Generate an APITestka Python snippet for *request*. +def apitestka_call_block(request: CurlRequest) -> str: + """Return the ``test_api_method_requests(...)`` call for *request*. - :param request: the parsed curl request - :return: Python source calling ``test_api_method_requests`` + The call is returned without the import, so several requests can be written + into one script. + + :param request: the parsed request + :return: the call statement, as one block """ lines = [ - "from je_api_testka import test_api_method_requests", - "", "response = test_api_method_requests(", f" {_inline_json(request.method)},", f" test_url={_inline_json(request.url)},", @@ -79,29 +83,32 @@ def to_apitestka_python(request: CurlRequest) -> str: f" auth=({_inline_json(request.username)}, " f"{_inline_json(request.password or '')}),") lines.append(")") - lines.append("") - lines.append("print(response)") + return "\n".join(lines) + + +def to_apitestka_python(request: CurlRequest) -> str: + """Generate an APITestka Python snippet for *request*. + + :param request: the parsed curl request + :return: Python source calling ``test_api_method_requests`` + """ + lines = [APITESTKA_IMPORT, "", apitestka_call_block(request), "", "print(response)"] return "\n".join(lines) + "\n" -def to_loaddensity_python(request: CurlRequest) -> str: - """Generate a LoadDensity (Locust) load-test snippet for *request*. +def loaddensity_start_block(request: CurlRequest) -> str: + """Return the ``start_test(...)`` call that load-tests *request*. - The basic Locust task issues the request by URL; headers and bodies are noted - but not sent, because the shared task template drives requests by URL only. + The call is returned without the import, so several requests can be written + into one script. - :param request: the parsed curl request - :return: Python source calling ``start_test`` + :param request: the parsed request + :return: the call statement, as one block """ method_key = request.method.lower() # Drive by the full URL so query params (from the URL or -G) are not lost. task = f"{{{_inline_json(method_key)}: {{\"request_url\": {_inline_json(request.full_url)}}}}}" lines = [ - "from je_load_density import start_test", - "", - "# Load-test the endpoint captured from the curl command.", - "# This basic task issues the request by URL; add headers/body in a custom", - "# Locust task if the endpoint needs them.", "start_test(", ' {"user": "fast_http_user"},', " user_count=50,", @@ -110,6 +117,26 @@ def to_loaddensity_python(request: CurlRequest) -> str: f" tasks={task},", ")", ] + return "\n".join(lines) + + +def to_loaddensity_python(request: CurlRequest) -> str: + """Generate a LoadDensity (Locust) load-test snippet for *request*. + + The basic Locust task issues the request by URL; headers and bodies are noted + but not sent, because the shared task template drives requests by URL only. + + :param request: the parsed curl request + :return: Python source calling ``start_test`` + """ + lines = [ + LOADDENSITY_IMPORT, + "", + "# Load-test the endpoint captured from the curl command.", + "# This basic task issues the request by URL; add headers/body in a custom", + "# Locust task if the endpoint needs them.", + loaddensity_start_block(request), + ] return "\n".join(lines) + "\n" @@ -144,6 +171,18 @@ def _apply_action_payload(request: CurlRequest, params: dict) -> None: params[kind[0]] = kind[1] +def to_apitestka_action(request: CurlRequest) -> list: + """Return the single ``["AT_test_api_method", {...}]`` action for *request*. + + Returned as data rather than text so several requests can be collected into + one action list. + + :param request: the parsed request + :return: the action pair + """ + return [_APITESTKA_ACTION, _apitestka_action_params(request)] + + def to_apitestka_action_json(request: CurlRequest) -> str: """Generate an APITestka JSON action list for *request*. @@ -152,8 +191,7 @@ def to_apitestka_action_json(request: CurlRequest) -> str: :param request: the parsed curl request :return: a formatted JSON action list """ - action = [[_APITESTKA_ACTION, _apitestka_action_params(request)]] - return json.dumps(action, indent=4, ensure_ascii=False) + "\n" + return json.dumps([to_apitestka_action(request)], indent=4, ensure_ascii=False) + "\n" def test_function_name(request: CurlRequest) -> str: @@ -176,17 +214,29 @@ def _indent(block: str) -> str: f"{_TEST_INDENT}{line}" if line else line for line in block.split("\n")) +def pytest_function(request: CurlRequest, name: str | None = None) -> str: + """Return the pytest function for *request*, without the import line. + + :param request: the parsed request + :param name: function name to use; derived from the request when omitted, + which a caller writing several tests into one file overrides to keep the + names unique + :return: the function definition, as one block + """ + lines = [f"def {name or test_function_name(request)}():"] + lines.extend(_indent(statement) for statement in request_statements(request)) + lines.append(f"{_TEST_INDENT}assert response.status_code == {_DEFAULT_EXPECTED_STATUS}") + lines.append(f"{_TEST_INDENT}# Add assertions on response.json() / response.text as needed") + return "\n".join(lines) + + def to_pytest_test(request: CurlRequest) -> str: """Generate a runnable pytest test that sends *request* and asserts the status. :param request: the parsed curl request :return: Python source defining a ``test_...`` function """ - lines = ["import requests", "", "", f"def {test_function_name(request)}():"] - lines.extend(_indent(statement) for statement in request_statements(request)) - lines.append(f"{_TEST_INDENT}assert response.status_code == {_DEFAULT_EXPECTED_STATUS}") - lines.append(f"{_TEST_INDENT}# Add assertions on response.json() / response.text as needed") - return "\n".join(lines) + "\n" + return "\n".join([REQUESTS_IMPORT, "", "", pytest_function(request)]) + "\n" # Target key -> (i18n label key, generator). The first entry is the default. diff --git a/pybreeze/utils/exception/exception_tags.py b/pybreeze/utils/exception/exception_tags.py index d43560b..1c2af4c 100644 --- a/pybreeze/utils/exception/exception_tags.py +++ b/pybreeze/utils/exception/exception_tags.py @@ -43,6 +43,12 @@ malformed_curl_command_error: str = "can't parse the curl command: check quoting" no_url_in_curl_error: str = "no URL found in the curl command" +# HAR import +empty_har_error: str = "no HAR content provided" +invalid_har_json_error: str = "can't parse the file as JSON: it is not a valid HAR export" +not_a_har_document_error: str = "the JSON has no log.entries list, so it is not a HAR export" +no_entries_in_har_error: str = "the HAR export contains no requests" + # JWT decode empty_jwt_error: str = "no token provided" malformed_jwt_error: str = "a JWT must have three dot-separated parts" diff --git a/pybreeze/utils/exception/exceptions.py b/pybreeze/utils/exception/exceptions.py index 69375b1..14f244b 100644 --- a/pybreeze/utils/exception/exceptions.py +++ b/pybreeze/utils/exception/exceptions.py @@ -58,6 +58,12 @@ class CurlParseException(ITEException): pass +# HAR import + +class HarParseException(ITEException): + pass + + # JWT decode class JwtDecodeException(ITEException): diff --git a/pybreeze/utils/har_import/__init__.py b/pybreeze/utils/har_import/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pybreeze/utils/har_import/har_codegen.py b/pybreeze/utils/har_import/har_codegen.py new file mode 100644 index 0000000..3752099 --- /dev/null +++ b/pybreeze/utils/har_import/har_codegen.py @@ -0,0 +1,138 @@ +"""Generate one script covering several recorded requests. + +The cURL importer generates a script for a single request; a HAR export holds a +whole session, so the same targets need a form that carries many requests at +once — one pytest file of several tests, one APITestka action list that replays +the flow in order, one ``requests`` script that walks through it. + +Each target is built from the same per-request blocks the single-request +templates use, so the two paths cannot drift apart. +""" +from __future__ import annotations + +import json +from collections.abc import Callable + +from pybreeze.utils.curl_import.curl_parser import CurlRequest +from pybreeze.utils.curl_import.request_codegen import REQUESTS_IMPORT, request_statements +from pybreeze.utils.curl_import.script_templates import ( + APITESTKA_IMPORT, + LOADDENSITY_IMPORT, + apitestka_call_block, + generate_template, + loaddensity_start_block, + pytest_function, + test_function_name, + to_apitestka_action, +) + +# Blank lines between two top-level definitions in generated Python +_BETWEEN_FUNCTIONS = "\n\n\n" + + +def unique_test_names(requests: list[CurlRequest]) -> list[str]: + """Derive one pytest function name per request, keeping them unique. + + Two recorded calls to the same endpoint would otherwise define the same + function twice, and the second would silently replace the first. + + :param requests: the requests to name, in order + :return: a name per request, suffixed ``_2``, ``_3`` … on a repeat + """ + names: list[str] = [] + seen: dict[str, int] = {} + for request in requests: + base = test_function_name(request) + seen[base] = seen.get(base, 0) + 1 + names.append(base if seen[base] == 1 else f"{base}_{seen[base]}") + return names + + +def _numbered_comment(index: int, request: CurlRequest) -> str: + """Return the comment introducing one request's block.""" + return f"# {index}. {request.method} {request.full_url}" + + +def _requests_script(requests: list[CurlRequest]) -> str: + """Write every request as ``requests`` statements in one script.""" + lines = [REQUESTS_IMPORT] + for index, request in enumerate(requests, start=1): + lines.append("") + lines.append(_numbered_comment(index, request)) + lines.extend(request_statements(request)) + lines.append("print(response.status_code)") + return "\n".join(lines) + "\n" + + +def _pytest_script(requests: list[CurlRequest]) -> str: + """Write every request as its own test in one pytest file.""" + functions = [ + pytest_function(request, name) + for request, name in zip(requests, unique_test_names(requests)) + ] + return "\n".join([REQUESTS_IMPORT, "", "", _BETWEEN_FUNCTIONS.join(functions)]) + "\n" + + +def _apitestka_python_script(requests: list[CurlRequest]) -> str: + """Write every request as an APITestka call in one script.""" + lines = [APITESTKA_IMPORT] + for index, request in enumerate(requests, start=1): + lines.append("") + lines.append(_numbered_comment(index, request)) + lines.append(apitestka_call_block(request)) + lines.append("print(response)") + return "\n".join(lines) + "\n" + + +def _apitestka_action_script(requests: list[CurlRequest]) -> str: + """Collect every request into one action list, replayed in capture order.""" + actions = [to_apitestka_action(request) for request in requests] + return json.dumps(actions, indent=4, ensure_ascii=False) + "\n" + + +def _loaddensity_script(requests: list[CurlRequest]) -> str: + """Write one load test per request. + + The shared Locust task template holds one URL per HTTP method, so merging + the requests into a single ``tasks`` dict would quietly drop all but the last + ``GET``. Each request therefore gets its own run to edit or delete. + """ + lines = [ + LOADDENSITY_IMPORT, + "", + "# One run per captured request: the shared task template drives one URL", + "# per HTTP method, so merging them would drop all but the last of each.", + ] + for index, request in enumerate(requests, start=1): + lines.append("") + lines.append(_numbered_comment(index, request)) + lines.append(loaddensity_start_block(request)) + return "\n".join(lines) + "\n" + + +# Target key -> the generator that writes several requests into one script. +_BATCH_GENERATORS: dict[str, Callable[[list[CurlRequest]], str]] = { + "requests": _requests_script, + "pytest": _pytest_script, + "apitestka_python": _apitestka_python_script, + "apitestka_action": _apitestka_action_script, + "loaddensity_python": _loaddensity_script, +} + + +def generate_har_script(target: str, requests: list[CurlRequest]) -> str: + """Generate one script for *target* covering every request in *requests*. + + A single request produces exactly what the cURL importer would produce for + it, so the two tools agree. + + :param target: a target key from ``TEMPLATE_TARGETS`` + :param requests: the requests to write, in capture order + :return: the generated script, or an empty string when there is nothing to write + """ + if not requests: + return "" + if len(requests) == 1: + return generate_template(target, requests[0]) + generator = _BATCH_GENERATORS.get(target, _requests_script) + return generator(requests) diff --git a/pybreeze/utils/har_import/har_parser.py b/pybreeze/utils/har_import/har_parser.py new file mode 100644 index 0000000..c4ac758 --- /dev/null +++ b/pybreeze/utils/har_import/har_parser.py @@ -0,0 +1,304 @@ +"""Read a HAR export and turn each recorded request into a :class:`CurlRequest`. + +"Copy as cURL" captures one request; **Save all as HAR** captures the whole +session. This module parses that export so the rest of PyBreeze can treat every +recorded call exactly like an imported curl command — same structure, same code +generators, same tools. + +A HAR is JSON, so nothing beyond the standard library is needed, and nothing here +is ever executed or replayed. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from urllib.parse import urlparse + +from pybreeze.utils.curl_import.curl_parser import CurlRequest, parse_query_pairs +from pybreeze.utils.exception.exception_tags import ( + empty_har_error, + invalid_har_json_error, + no_entries_in_har_error, + not_a_har_document_error, +) +from pybreeze.utils.exception.exceptions import HarParseException +from pybreeze.utils.header_tools.header_merge import add_header, stored_header_name +from pybreeze.utils.logging.logger import pybreeze_logger + +# Media type of a URL-encoded form body +_FORM_MEDIA_TYPE = "application/x-www-form-urlencoded" +# Media type prefix of a multipart form body +_MULTIPART_MEDIA_TYPE = "multipart/form-data" +# Header carrying the request body's media type +_CONTENT_TYPE_HEADER = "content-type" +# Header carrying the request's cookies +_COOKIE_HEADER = "cookie" + +# Response media types that are page furniture rather than an API call +_STATIC_MEDIA_TYPES = ( + "text/css", "text/javascript", "image/", "font/", "video/", "audio/", + "application/javascript", "application/x-javascript", "application/font", +) +# File extensions that mark a static asset regardless of the media type sent +_STATIC_EXTENSIONS = ( + ".css", ".js", ".mjs", ".map", ".png", ".jpg", ".jpeg", ".gif", ".svg", + ".webp", ".avif", ".ico", ".woff", ".woff2", ".ttf", ".otf", ".eot", + ".mp4", ".webm", ".mp3", ".wav", +) + + +@dataclass +class HarEntry: + """One request recorded in a HAR export. + + :param request: the recorded request, in the same shape a curl command parses + into, so every existing generator and tool accepts it + :param status: the recorded response status, or ``None`` when absent + :param response_media_type: the response's media type, or an empty string + :param started: the recorded start time, as written in the export + """ + + request: CurlRequest + status: int | None = None + response_media_type: str = "" + started: str = "" + + @property + def path(self) -> str: + """The request's path (with query), for display in a compact list.""" + parsed = urlparse(self.request.full_url) + return f"{parsed.path or '/'}{'?' + parsed.query if parsed.query else ''}" + + @property + def host(self) -> str: + """The request's host, or an empty string when the URL has none.""" + return urlparse(self.request.url).hostname or "" + + def summary(self) -> str: + """A one-line description: method, path, status and media type.""" + parts = [self.request.method, self.path] + if self.status is not None: + parts.append(str(self.status)) + if self.response_media_type: + parts.append(self.response_media_type) + return " ".join(parts) + + +def _header_pairs(raw_headers: object) -> list[tuple[str, str]]: + """Return the ``(name, value)`` pairs of a HAR header/query/cookie list. + + HTTP/2 pseudo-headers (``:method``, ``:authority``) are dropped: they are + frame metadata, not headers a client sets, and passing them on would produce + code that fails. + """ + if not isinstance(raw_headers, list): + return [] + pairs: list[tuple[str, str]] = [] + for item in raw_headers: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip() + if not name or name.startswith(":"): + continue + pairs.append((name, str(item.get("value", "")))) + return pairs + + +def _apply_headers(request: CurlRequest, raw_request: dict) -> None: + """Collect the entry's headers, combining any repeated name.""" + for name, value in _header_pairs(raw_request.get("headers")): + add_header(request.headers, name, value) + + +def _apply_cookies(request: CurlRequest, raw_request: dict) -> None: + """Collect the entry's cookies, and drop the header that duplicates them. + + A HAR records cookies both as a structured list and inside the ``Cookie`` + header. Keeping both would send every cookie twice, so the structured list + wins — it is the one the generated code can edit. + """ + for name, value in _header_pairs(raw_request.get("cookies")): + request.cookies[name] = value + if not request.cookies: + return + stored = stored_header_name(request.headers, _COOKIE_HEADER) + if stored is not None: + del request.headers[stored] + + +def _apply_query(request: CurlRequest, raw_request: dict, query: str) -> None: + """Collect the query parameters from the URL, then from ``queryString``. + + The URL is authoritative because it is what was actually sent; the recorded + ``queryString`` list only fills in what the URL did not carry. + """ + for key, value in parse_query_pairs(query.split("&")).items(): + request.params.setdefault(key, value) + for name, value in _header_pairs(raw_request.get("queryString")): + request.params.setdefault(name, value) + + +def _multipart_fields(params: list[tuple[str, str, str]]) -> list[str]: + """Render multipart params in curl's ``-F`` syntax so form handling is shared.""" + return [ + f"{name}=@{file_name}" if file_name else f"{name}={value}" + for name, value, file_name in params + ] + + +def _post_params(raw_post: dict) -> list[tuple[str, str, str]]: + """Return the ``(name, value, fileName)`` triples of a recorded form body.""" + raw_params = raw_post.get("params") + if not isinstance(raw_params, list): + return [] + triples: list[tuple[str, str, str]] = [] + for item in raw_params: + if isinstance(item, dict) and item.get("name"): + triples.append(( + str(item["name"]), str(item.get("value", "")), str(item.get("fileName", "")))) + return triples + + +def _apply_body(request: CurlRequest, raw_request: dict) -> None: + """Collect the recorded request body as data parts or form fields.""" + raw_post = raw_request.get("postData") + if not isinstance(raw_post, dict): + return + media_type = str(raw_post.get("mimeType", "")).lower() + params = _post_params(raw_post) + if params and media_type.startswith(_MULTIPART_MEDIA_TYPE): + request.form_fields.extend(_multipart_fields(params)) + return + if params and _FORM_MEDIA_TYPE in media_type: + request.data_parts.extend(f"{name}={value}" for name, value, _file in params) + return + text = raw_post.get("text") + if text: + request.data_parts.append(str(text)) + + +def _entry_request(raw_request: dict) -> CurlRequest: + """Build a :class:`CurlRequest` from a HAR entry's ``request`` object.""" + url = str(raw_request.get("url", "")) + base, _separator, query = url.partition("?") + request = CurlRequest( + method=str(raw_request.get("method", "GET")).upper(), url=base) + _apply_headers(request, raw_request) + _apply_cookies(request, raw_request) + _apply_query(request, raw_request, query) + _apply_body(request, raw_request) + return request + + +def _response_details(raw_response: object) -> tuple[int | None, str]: + """Return the recorded ``(status, media type)`` of an entry's response.""" + if not isinstance(raw_response, dict): + return None, "" + status = raw_response.get("status") + content = raw_response.get("content") + media_type = "" + if isinstance(content, dict): + media_type = str(content.get("mimeType", "")).split(";")[0].strip() + return (status if isinstance(status, int) and status > 0 else None), media_type + + +def _load_entries(text: str) -> list[dict]: + """Return the raw entry list of a HAR document. + + :raises HarParseException: when the text is empty, is not JSON, or has no + ``log.entries`` list + """ + if not text.strip(): + pybreeze_logger.error(empty_har_error) + raise HarParseException(empty_har_error) + try: + document = json.loads(text) + except ValueError as error: + pybreeze_logger.error(invalid_har_json_error) + raise HarParseException(invalid_har_json_error) from error + log = document.get("log") if isinstance(document, dict) else None + entries = log.get("entries") if isinstance(log, dict) else None + if not isinstance(entries, list): + pybreeze_logger.error(not_a_har_document_error) + raise HarParseException(not_a_har_document_error) + return [entry for entry in entries if isinstance(entry, dict)] + + +def parse_har(text: str) -> list[HarEntry]: + """Parse a HAR export into one entry per recorded request. + + :param text: the contents of a ``.har`` file + :return: the recorded requests, in the order they were captured + :raises HarParseException: when the text is not a HAR export, or records no + request with a URL + """ + entries: list[HarEntry] = [] + for raw_entry in _load_entries(text): + raw_request = raw_entry.get("request") + if not isinstance(raw_request, dict) or not raw_request.get("url"): + continue + status, media_type = _response_details(raw_entry.get("response")) + entries.append(HarEntry( + request=_entry_request(raw_request), + status=status, + response_media_type=media_type, + started=str(raw_entry.get("startedDateTime", "")), + )) + if not entries: + pybreeze_logger.error(no_entries_in_har_error) + raise HarParseException(no_entries_in_har_error) + return entries + + +def is_api_like(entry: HarEntry) -> bool: + """Whether *entry* looks like an API call rather than a page asset. + + A recorded session is mostly stylesheets, images and fonts; those are not + worth generating a test for. An entry is treated as an asset when its + response media type or its path extension says so. + + :param entry: the recorded entry + :return: ``True`` when the entry is worth generating code for + """ + media_type = entry.response_media_type.lower() + if media_type.startswith(_STATIC_MEDIA_TYPES): + return False + path = urlparse(entry.request.url).path.lower() + return not path.endswith(_STATIC_EXTENSIONS) + + +def api_entries(entries: list[HarEntry]) -> list[HarEntry]: + """Return only the entries that look like API calls. + + :param entries: the parsed entries + :return: the subset worth generating code for + """ + return [entry for entry in entries if is_api_like(entry)] + + +@dataclass +class HarSummary: + """Counts describing what a parsed export contains. + + :param total: every recorded request + :param api: those that look like API calls + :param hosts: the distinct hosts contacted, in first-seen order + """ + + total: int = 0 + api: int = 0 + hosts: list[str] = field(default_factory=list) + + +def summarize(entries: list[HarEntry]) -> HarSummary: + """Summarise a parsed export for display above the entry list. + + :param entries: the parsed entries + :return: the counts and the hosts involved + """ + hosts: list[str] = [] + for entry in entries: + host = entry.host + if host and host not in hosts: + hosts.append(host) + return HarSummary(total=len(entries), api=len(api_entries(entries)), hosts=hosts) diff --git a/pybreeze/utils/header_tools/header_merge.py b/pybreeze/utils/header_tools/header_merge.py new file mode 100644 index 0000000..6d47d05 --- /dev/null +++ b/pybreeze/utils/header_tools/header_merge.py @@ -0,0 +1,71 @@ +"""Collect HTTP headers the way a receiver collects repeated field lines. + +The curl parser and the HAR importer both gather headers one at a time from a +source that may repeat a name — two ``-H`` flags, two entries in a HAR header +list — so the rule for combining them lives here once rather than in each. + +Names are compared case-insensitively, as HTTP defines them, and the spelling +first seen is the one kept. +""" +from __future__ import annotations + +# Header whose repeated values are joined with "; " rather than ", " +_COOKIE_HEADER = "cookie" +# How repeated header lines are combined into a single value +_HEADER_JOINER = ", " +_COOKIE_JOINER = "; " + + +def stored_header_name(headers: dict[str, str], name: str) -> str | None: + """Return the spelling *name* is already stored under, or ``None``. + + :param headers: the headers collected so far + :param name: the header name to look for, in any casing + :return: the stored spelling, or ``None`` when the header is absent + """ + lowered = name.lower() + for stored in headers: + if stored.lower() == lowered: + return stored + return None + + +def join_header_values(name: str, previous: str, value: str) -> str: + """Combine a repeated header's values the way HTTP combines field lines. + + :param name: the header name (decides the separator) + :param previous: the value collected so far + :param value: the value to append + :return: the combined value; an empty side is dropped rather than joined + """ + if not previous: + return value + if not value: + return previous + joiner = _COOKIE_JOINER if name.lower() == _COOKIE_HEADER else _HEADER_JOINER + return joiner.join((previous, value)) + + +def add_header(headers: dict[str, str], name: str, value: str) -> None: + """Record ``name: value`` in *headers*, combining repeats of the same name. + + :param headers: the headers to add to, modified in place + :param name: the header name + :param value: the header value + """ + stored = stored_header_name(headers, name) + if stored is None: + headers[name] = value + return + headers[stored] = join_header_values(stored, headers[stored], value) + + +def set_default_header(headers: dict[str, str], name: str, value: str) -> None: + """Set ``name: value`` only when no header of that name is present. + + :param headers: the headers to add to, modified in place + :param name: the header name + :param value: the value to use when the header is absent + """ + if stored_header_name(headers, name) is None: + headers[name] = value diff --git a/pybreeze/utils/jwt_tools/jwt_decoder.py b/pybreeze/utils/jwt_tools/jwt_decoder.py index a106d63..4218882 100644 --- a/pybreeze/utils/jwt_tools/jwt_decoder.py +++ b/pybreeze/utils/jwt_tools/jwt_decoder.py @@ -12,6 +12,7 @@ import base64 import json +import re from dataclasses import dataclass from datetime import datetime, timezone @@ -27,6 +28,9 @@ _JWT_SEGMENT_COUNT = 3 # Standard claim names that hold Unix timestamps, shown as readable dates _TIMESTAMP_CLAIMS = ("exp", "iat", "nbf", "auth_time") +# Matches a JWT-looking token anywhere in a larger text, such as the value of an +# ``Authorization: Bearer ...`` header. The signature segment may be empty. +JWT_TOKEN_RE = re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*") @dataclass @@ -88,6 +92,23 @@ def decode_jwt(token: str) -> DecodedJwt: return DecodedJwt(header=header, payload=payload, signature=segments[2]) +def find_tokens(text: str) -> list[str]: + """Find every JWT-looking token in *text*, in order and without repeats. + + Used to spot a token inside something larger — a response body, an + ``Authorization`` header — so it can be handed to the decoder. A match only + *looks* like a JWT; decoding it may still fail. + + :param text: the text to scan + :return: the distinct tokens found, in the order they appeared + """ + tokens: list[str] = [] + for token in JWT_TOKEN_RE.findall(text): + if token not in tokens: + tokens.append(token) + return tokens + + def format_timestamp_claim(value: object) -> str | None: """Render a Unix-timestamp claim as an ISO UTC string, or ``None``. diff --git a/pybreeze/utils/response_inspector/response_analyzer.py b/pybreeze/utils/response_inspector/response_analyzer.py index fef4500..30bd08a 100644 --- a/pybreeze/utils/response_inspector/response_analyzer.py +++ b/pybreeze/utils/response_inspector/response_analyzer.py @@ -16,13 +16,11 @@ from pybreeze.utils.header_tools.header_analyzer import HEADER_LINE_RE from pybreeze.utils.http_reference.status_codes import StatusInfo, lookup -from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt +from pybreeze.utils.jwt_tools.jwt_decoder import DecodedJwt, decode_jwt, find_tokens from pybreeze.utils.exception.exceptions import JwtDecodeException # Matches the response status line, e.g. "HTTP/1.1 200 OK" _STATUS_LINE_RE = re.compile(r"^HTTP/\d(?:\.\d)?\s+(\d{3})\b") -# Matches a JWT-looking token (three base64url segments; the signature may be empty) -_JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*") @dataclass @@ -101,13 +99,9 @@ def _pretty_json(body: str) -> str | None: def _find_jwts(text: str) -> list[JwtFinding]: - """Find and decode every JWT-looking token in *text* (deduplicated).""" + """Find and decode every JWT-looking token in *text*.""" findings: list[JwtFinding] = [] - seen: set[str] = set() - for token in _JWT_RE.findall(text): - if token in seen: - continue - seen.add(token) + for token in find_tokens(text): try: findings.append(JwtFinding(token=token, decoded=decode_jwt(token))) except JwtDecodeException: diff --git a/test/test_utils/test_curl_import_gui.py b/test/test_utils/test_curl_import_gui.py index 2bf5305..a16a779 100644 --- a/test/test_utils/test_curl_import_gui.py +++ b/test/test_utils/test_curl_import_gui.py @@ -247,3 +247,33 @@ def test_open_without_window_is_safe(self, widget): widget.input_edit.setPlainText("curl https://example.com/api") widget.convert() assert widget.open_url_in_builder() is None + + +class TestCurlImportOpenHeadersInAnalyzer: + def test_button_disabled_without_headers(self, widget): + widget.input_edit.setPlainText("curl https://example.com/api") + widget.convert() + assert not widget.open_headers_button.isEnabled() + + def test_button_enabled_when_headers_were_parsed(self, widget): + widget.input_edit.setPlainText("curl https://x -H 'Authorization: Bearer abc'") + widget.convert() + assert widget.open_headers_button.isEnabled() + + def test_open_headers_opens_prefilled_analyzer(self, widget_with_window): + from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI + gui, window = widget_with_window + gui.input_edit.setPlainText( + "curl https://x -H 'Accept: application/json' -H 'X-Trace: 1'") + gui.convert() + gui.open_headers_in_analyzer() + opened = window.tab_widget.added[0][0] + assert isinstance(opened, HeaderAnalyzerGUI) + output = opened.output_edit.toPlainText() + assert "Accept: application/json" in output + assert "X-Trace: 1" 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 + assert window.tab_widget.added == [] diff --git a/test/test_utils/test_fuzz_pure_logic.py b/test/test_utils/test_fuzz_pure_logic.py index 74b53e7..22e87d7 100644 --- a/test/test_utils/test_fuzz_pure_logic.py +++ b/test/test_utils/test_fuzz_pure_logic.py @@ -1,6 +1,8 @@ """Property-based fuzzing: pure-logic helpers must never crash on hostile input.""" from __future__ import annotations +import json + from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -14,7 +16,8 @@ _is_text_content_type, _parse_content_length, ) -from pybreeze.utils.exception.exceptions import ITEJsonException +from pybreeze.utils.exception.exceptions import HarParseException, ITEJsonException +from pybreeze.utils.har_import.har_parser import parse_har from pybreeze.utils.header_tools.header_analyzer import ( LEVEL_INFO, LEVEL_WARNING, @@ -112,6 +115,32 @@ def test_header_like_text(self, text): assert sum(1 for f in analysis.fields if f.name.lower() == name) == count +class TestHarParserNeverCrashes: + @_FUZZ + @given(st.text()) + def test_arbitrary_text(self, text): + # Must return entries or raise the documented HarParseException — never a + # bare KeyError/TypeError from a HAR field being an unexpected shape. + try: + assert isinstance(parse_har(text), list) + except HarParseException: + pass + + @_FUZZ + @given(st.recursive( + st.none() | st.booleans() | st.integers() | st.text(), + lambda children: st.lists(children, max_size=3) | st.dictionaries( + st.text(max_size=8), children, max_size=3), + max_leaves=12)) + def test_arbitrary_json_document(self, document): + # A HAR whose fields hold the wrong types must still be handled. + try: + for entry in parse_har(json.dumps({"log": {"entries": [document]}})): + assert isinstance(entry.summary(), str) + except HarParseException: + pass + + class TestSsrfLogicNeverCrashes: @_FUZZ @given(st.ip_addresses(v=4)) diff --git a/test/test_utils/test_har_import.py b/test/test_utils/test_har_import.py new file mode 100644 index 0000000..70b4719 --- /dev/null +++ b/test/test_utils/test_har_import.py @@ -0,0 +1,291 @@ +"""Tests for the HAR export parser and its multi-request code generation.""" +from __future__ import annotations + +import json + +import pytest + +from pybreeze.utils.exception.exceptions import HarParseException +from pybreeze.utils.har_import.har_codegen import generate_har_script, unique_test_names +from pybreeze.utils.har_import.har_parser import ( + api_entries, + is_api_like, + parse_har, + summarize, +) + + +def _har(*entries: dict) -> str: + """Wrap raw entry dicts in a minimal HAR document.""" + return json.dumps({"log": {"version": "1.2", "entries": list(entries)}}) + + +def _entry( + url: str = "https://api.example.com/v1/items", + method: str = "GET", + headers: list | None = None, + cookies: list | None = None, + query: list | None = None, + post_data: dict | None = None, + status: int = 200, + mime: str = "application/json", + started: str = "2026-07-25T00:00:00.000Z") -> dict: + """Build one HAR entry, with only the parts a test cares about filled in.""" + request: dict = {"method": method, "url": url, "headers": headers or []} + if cookies is not None: + request["cookies"] = cookies + if query is not None: + request["queryString"] = query + if post_data is not None: + request["postData"] = post_data + return { + "startedDateTime": started, + "request": request, + "response": {"status": status, "content": {"mimeType": mime}}, + } + + +class TestParseHarBasics: + def test_method_and_url(self): + entry = parse_har(_har(_entry()))[0] + assert entry.request.method == "GET" + assert entry.request.url == "https://api.example.com/v1/items" + + def test_method_is_uppercased(self): + assert parse_har(_har(_entry(method="post")))[0].request.method == "POST" + + def test_response_details_are_kept(self): + entry = parse_har(_har(_entry(status=404, mime="application/json; charset=utf-8")))[0] + assert entry.status == 404 + assert entry.response_media_type == "application/json" + + def test_started_time_is_kept(self): + assert parse_har(_har(_entry()))[0].started == "2026-07-25T00:00:00.000Z" + + def test_entries_keep_capture_order(self): + document = _har(_entry(url="https://x/one"), _entry(url="https://x/two")) + assert [e.request.url for e in parse_har(document)] == ["https://x/one", "https://x/two"] + + def test_entry_without_url_is_skipped(self): + document = _har({"request": {"method": "GET"}}, _entry()) + assert len(parse_har(document)) == 1 + + def test_missing_response_leaves_status_none(self): + document = _har({"request": {"method": "GET", "url": "https://x/a"}}) + assert parse_har(document)[0].status is None + + +class TestParseHarErrors: + def test_empty_text(self): + with pytest.raises(HarParseException): + parse_har(" ") + + def test_not_json(self): + with pytest.raises(HarParseException): + parse_har("not json at all") + + def test_json_without_log_entries(self): + with pytest.raises(HarParseException): + parse_har('{"log": {"version": "1.2"}}') + + def test_json_array(self): + with pytest.raises(HarParseException): + parse_har("[1, 2, 3]") + + def test_no_usable_entries(self): + with pytest.raises(HarParseException): + parse_har(_har()) + + +class TestHeadersAndCookies: + def test_headers_are_collected(self): + entry = parse_har(_har(_entry(headers=[ + {"name": "Accept", "value": "application/json"}, + {"name": "X-Token", "value": "abc"}, + ])))[0] + assert entry.request.headers == {"Accept": "application/json", "X-Token": "abc"} + + def test_repeated_header_is_combined(self): + entry = parse_har(_har(_entry(headers=[ + {"name": "Accept", "value": "text/html"}, + {"name": "accept", "value": "application/json"}, + ])))[0] + assert entry.request.headers == {"Accept": "text/html, application/json"} + + def test_http2_pseudo_headers_are_dropped(self): + entry = parse_har(_har(_entry(headers=[ + {"name": ":method", "value": "GET"}, + {"name": ":authority", "value": "api.example.com"}, + {"name": "Accept", "value": "*/*"}, + ])))[0] + assert entry.request.headers == {"Accept": "*/*"} + + def test_cookies_become_a_dict(self): + entry = parse_har(_har(_entry(cookies=[ + {"name": "sid", "value": "abc"}, {"name": "theme", "value": "dark"}, + ])))[0] + assert entry.request.cookies == {"sid": "abc", "theme": "dark"} + + def test_cookie_header_is_dropped_when_cookies_were_recorded(self): + # Keeping both would send every cookie twice. + entry = parse_har(_har(_entry( + headers=[{"name": "Cookie", "value": "sid=abc"}], + cookies=[{"name": "sid", "value": "abc"}])))[0] + assert entry.request.cookies == {"sid": "abc"} + assert "Cookie" not in entry.request.headers + + 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" + + +class TestQueryParameters: + def test_url_query_moves_into_params(self): + entry = parse_har(_har(_entry(url="https://x/api?a=1&b=2")))[0] + assert entry.request.url == "https://x/api" + assert entry.request.params == {"a": "1", "b": "2"} + + def test_query_string_list_fills_in_what_the_url_lacks(self): + entry = parse_har(_har(_entry( + url="https://x/api", query=[{"name": "page", "value": "2"}])))[0] + assert entry.request.params == {"page": "2"} + + def test_url_wins_over_the_recorded_list(self): + entry = parse_har(_har(_entry( + url="https://x/api?page=1", query=[{"name": "page", "value": "9"}])))[0] + assert entry.request.params == {"page": "1"} + + def test_full_url_rebuilds_the_address(self): + entry = parse_har(_har(_entry(url="https://x/api?a=1")))[0] + assert entry.request.full_url == "https://x/api?a=1" + + +class TestRequestBody: + def test_raw_text_body(self): + entry = parse_har(_har(_entry(method="POST", post_data={ + "mimeType": "application/json", "text": '{"a": 1}'})))[0] + assert entry.request.body == '{"a": 1}' + + def test_urlencoded_params_become_body_pairs(self): + entry = parse_har(_har(_entry(method="POST", post_data={ + "mimeType": "application/x-www-form-urlencoded", + "params": [{"name": "a", "value": "1"}, {"name": "b", "value": "2"}]})))[0] + assert entry.request.body == "a=1&b=2" + + def test_multipart_params_become_form_fields(self): + entry = parse_har(_har(_entry(method="POST", post_data={ + "mimeType": "multipart/form-data; boundary=x", + "params": [ + {"name": "note", "value": "hi"}, + {"name": "file", "value": "", "fileName": "a.png"}, + ]})))[0] + assert entry.request.form_fields == ["note=hi", "file=@a.png"] + + def test_no_post_data_leaves_no_body(self): + assert not parse_har(_har(_entry()))[0].request.has_body + + +class TestApiFiltering: + def test_json_response_is_api_like(self): + assert is_api_like(parse_har(_har(_entry()))[0]) + + def test_stylesheet_is_not_api_like(self): + entry = parse_har(_har(_entry(url="https://x/app.css", mime="text/css")))[0] + assert not is_api_like(entry) + + def test_image_is_not_api_like(self): + entry = parse_har(_har(_entry(url="https://x/logo.png", mime="image/png")))[0] + assert not is_api_like(entry) + + def test_asset_extension_without_media_type_is_not_api_like(self): + entry = parse_har(_har(_entry(url="https://x/app.js", mime="")))[0] + assert not is_api_like(entry) + + def test_api_entries_keeps_only_calls(self): + document = _har( + _entry(url="https://x/api/items"), + _entry(url="https://x/app.css", mime="text/css"), + _entry(url="https://x/logo.png", mime="image/png")) + assert [e.request.url for e in api_entries(parse_har(document))] == ["https://x/api/items"] + + +class TestSummary: + def test_counts_and_hosts(self): + document = _har( + _entry(url="https://a.com/api/items"), + _entry(url="https://a.com/app.css", mime="text/css"), + _entry(url="https://b.com/api/users")) + summary = summarize(parse_har(document)) + assert summary.total == 3 + assert summary.api == 2 + assert summary.hosts == ["a.com", "b.com"] + + def test_entry_summary_line(self): + line = parse_har(_har(_entry(url="https://x/api/items?a=1")))[0].summary() + assert "GET" in line and "/api/items?a=1" in line and "200" in line + + +class TestUniqueTestNames: + def test_distinct_paths_keep_their_names(self): + requests = [e.request for e in parse_har(_har( + _entry(url="https://x/api/items"), _entry(url="https://x/api/users")))] + assert unique_test_names(requests) == ["test_get_api_items", "test_get_api_users"] + + def test_repeated_endpoint_is_numbered(self): + requests = [e.request for e in parse_har(_har( + _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"] + + +class TestGenerateHarScript: + def _requests(self, *urls: str): + return [e.request for e in parse_har(_har(*[_entry(url=url) for url in urls]))] + + def test_no_requests_yields_empty_text(self): + assert generate_har_script("requests", []) == "" + + def test_single_request_matches_the_curl_importer(self): + from pybreeze.utils.curl_import.script_templates import generate_template + requests = self._requests("https://x/api/items") + assert generate_har_script("pytest", requests) == generate_template("pytest", requests[0]) + + def test_requests_script_covers_every_request(self): + code = generate_har_script("requests", self._requests("https://x/one", "https://x/two")) + assert code.count("import requests") == 1 + assert "https://x/one" in code and "https://x/two" in code + + def test_pytest_script_defines_one_test_per_request(self): + code = generate_har_script("pytest", self._requests("https://x/api/a", "https://x/api/b")) + assert "def test_get_api_a():" in code + assert "def test_get_api_b():" in code + assert code.count("import requests") == 1 + + def test_pytest_script_never_defines_the_same_test_twice(self): + code = generate_har_script("pytest", self._requests("https://x/api/a", "https://x/api/a")) + assert "def test_get_api_a():" in code + assert "def test_get_api_a_2():" in code + + def test_apitestka_action_script_is_one_action_list(self): + code = generate_har_script( + "apitestka_action", self._requests("https://x/api/a", "https://x/api/b")) + actions = json.loads(code) + assert [action[0] for action in actions] == ["AT_test_api_method"] * 2 + assert [action[1]["test_url"] for action in actions] == [ + "https://x/api/a", "https://x/api/b"] + + def test_apitestka_python_script_imports_once(self): + code = generate_har_script( + "apitestka_python", self._requests("https://x/api/a", "https://x/api/b")) + assert code.count("from je_api_testka import") == 1 + assert code.count("test_api_method_requests(") == 2 + + def test_loaddensity_script_keeps_every_request(self): + # Merging into one tasks dict would drop all but the last GET. + code = generate_har_script( + "loaddensity_python", self._requests("https://x/api/a", "https://x/api/b")) + assert code.count("start_test(") == 2 + assert "https://x/api/a" in code and "https://x/api/b" in code + + 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 diff --git a/test/test_utils/test_har_import_gui.py b/test/test_utils/test_har_import_gui.py new file mode 100644 index 0000000..d5d1d5b --- /dev/null +++ b/test/test_utils/test_har_import_gui.py @@ -0,0 +1,173 @@ +"""Tests for the HAR import tool widget.""" +from __future__ import annotations + +import json +import os +from unittest.mock import patch + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication + +from pybreeze.extend_multi_language.extend_english import pybreeze_english_word_dict as EN +from pybreeze.extend_multi_language.update_language_dict import update_language_dict + +_HAR = json.dumps({"log": {"version": "1.2", "entries": [ + { + "startedDateTime": "2026-07-25T00:00:00.000Z", + "request": { + "method": "GET", "url": "https://api.example.com/v1/items?page=1", + "headers": [{"name": "Accept", "value": "application/json"}], + }, + "response": {"status": 200, "content": {"mimeType": "application/json"}}, + }, + { + "startedDateTime": "2026-07-25T00:00:01.000Z", + "request": { + "method": "POST", "url": "https://api.example.com/v1/items", + "headers": [{"name": "Content-Type", "value": "application/json"}], + "postData": {"mimeType": "application/json", "text": "{\"name\": \"x\"}"}, + }, + "response": {"status": 201, "content": {"mimeType": "application/json"}}, + }, + { + "startedDateTime": "2026-07-25T00:00:02.000Z", + "request": {"method": "GET", "url": "https://cdn.example.com/app.css", "headers": []}, + "response": {"status": 200, "content": {"mimeType": "text/css"}}, + }, +]}}) + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + update_language_dict() + return instance + + +@pytest.fixture() +def widget(app): + from pybreeze.pybreeze_ui.tools_gui.har_import_gui import HarImportGUI + gui = HarImportGUI() + yield gui + gui.close() + gui.deleteLater() + + +@pytest.fixture() +def loaded(widget): + widget.load_text(_HAR) + return widget + + +class TestHarImportLoading: + def test_load_lists_api_requests_only_by_default(self, loaded): + assert loaded.entry_list.count() == 2 + + def test_unchecking_the_filter_lists_every_request(self, loaded): + loaded.api_only_check.setChecked(False) + assert loaded.entry_list.count() == 3 + + def test_entry_rows_describe_the_request(self, loaded): + first = loaded.entry_list.item(0).text() + assert "GET" in first and "/v1/items?page=1" in first and "200" in first + + def test_summary_reports_counts_and_hosts(self, loaded): + summary = loaded.summary_label.text() + assert "3" in summary and "2" in summary + assert "api.example.com" in summary + + def test_invalid_har_reports_an_error(self, widget): + assert widget.load_text("not a har") is False + assert widget.entry_list.count() == 0 + assert widget.output_edit.toPlainText() != "" + + def test_error_then_valid_load_recovers(self, widget): + widget.load_text("not a har") + assert widget.load_text(_HAR) is True + assert widget.entry_list.count() == 2 + + +class TestHarImportGeneration: + def _select_target(self, widget, target_key): + widget.target_select.setCurrentIndex(widget.target_select.findData(target_key)) + + def test_generate_all_covers_every_listed_request(self, loaded): + loaded.generate_all() + code = loaded.output_edit.toPlainText() + assert "/v1/items" in code + assert "app.css" not in code # filtered out, so it must not be generated + + def test_generate_all_includes_assets_when_the_filter_is_off(self, loaded): + loaded.api_only_check.setChecked(False) + loaded.generate_all() + assert "app.css" in loaded.output_edit.toPlainText() + + def test_generate_selected_covers_only_the_selection(self, loaded): + loaded.entry_list.setCurrentRow(1) + loaded.generate_selected() + code = loaded.output_edit.toPlainText() + assert "POST" in code + assert "page" not in code + + def test_generate_without_selection_shows_the_hint(self, loaded): + loaded.entry_list.clearSelection() + loaded.generate_selected() + assert loaded.output_edit.toPlainText() == EN["har_import_no_selection"] + + def test_pytest_target_writes_one_test_per_request(self, loaded): + self._select_target(loaded, "pytest") + loaded.generate_all() + code = loaded.output_edit.toPlainText() + assert code.count("def test_") == 2 + + def test_action_target_writes_one_action_list(self, loaded): + self._select_target(loaded, "apitestka_action") + loaded.generate_all() + actions = json.loads(loaded.output_edit.toPlainText()) + assert len(actions) == 2 + + def test_suggested_filename_follows_the_target(self, loaded): + self._select_target(loaded, "apitestka_action") + assert loaded.actions.suggested_filename() == "actions.json" + self._select_target(loaded, "pytest") + assert loaded.actions.suggested_filename() == "session.py" + + def test_copy_output(self, app, loaded): + loaded.generate_all() + loaded.actions.copy() + assert "/v1/items" in QApplication.clipboard().text() + + def test_save_before_generating_is_noop(self, loaded): + assert loaded.actions.save_to_file() is None + + +class TestHarImportFileDialog: + def test_open_file_loads_the_chosen_export(self, widget, tmp_path): + path = tmp_path / "session.har" + path.write_text(_HAR, encoding="utf-8") + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(path), "HAR export (*.har *.json)"), + ): + result = widget.open_file() + assert result == 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", + return_value=("", ""), + ): + assert widget.open_file() is None + assert widget.entry_list.count() == 0 + + def test_unreadable_file_reports_an_error(self, widget, tmp_path): + missing = tmp_path / "gone.har" + with patch( + "pybreeze.pybreeze_ui.tools_gui.har_import_gui.QFileDialog.getOpenFileName", + return_value=(str(missing), ""), + ): + assert widget.open_file() is None + assert widget.output_edit.toPlainText() != "" diff --git a/test/test_utils/test_header_analyzer_gui.py b/test/test_utils/test_header_analyzer_gui.py index b498b93..aa8acb1 100644 --- a/test/test_utils/test_header_analyzer_gui.py +++ b/test/test_utils/test_header_analyzer_gui.py @@ -95,6 +95,77 @@ def test_save_after_no_headers_is_noop(self, widget): assert widget.actions.save_to_file() is None +class _FakeTabWidget: + def __init__(self): + self.added = [] + self.current = None + + def addTab(self, widget, label): + self.added.append((widget, label)) + + def setCurrentWidget(self, widget): + self.current = widget + + +class _FakeMainWindow: + def __init__(self): + self.tab_widget = _FakeTabWidget() + + +def _jwt(payload: dict) -> str: + import base64 + import json as json_module + + def seg(obj): + raw = json_module.dumps(obj).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + return f"{seg({'alg': 'HS256'})}.{seg(payload)}.sig" + + +class TestHeaderAnalyzerJwtHandOff: + def test_button_disabled_without_a_token(self, widget): + widget.input_edit.setPlainText(_RESPONSE) + widget.analyze() + assert not widget.open_jwt_button.isEnabled() + + def test_button_enabled_when_a_header_carries_a_token(self, widget): + widget.input_edit.setPlainText(f"Authorization: Bearer {_jwt({'sub': '42'})}") + widget.analyze() + assert widget.open_jwt_button.isEnabled() + + def test_token_is_found_in_any_header(self, widget): + token = _jwt({"sub": "7"}) + widget.input_edit.setPlainText(f"X-Custom-Auth: {token}") + widget.analyze() + assert widget.header_tokens() == [token] + + def test_open_jwt_opens_prefilled_decoder(self, app): + from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI + from pybreeze.pybreeze_ui.tools_gui.jwt_decoder_gui import JwtDecoderGUI + window = _FakeMainWindow() + gui = HeaderAnalyzerGUI(main_window=window) + gui.input_edit.setPlainText(f"Authorization: Bearer {_jwt({'sub': '99'})}") + gui.analyze() + gui.open_jwt_in_decoder() + opened = window.tab_widget.added[0][0] + assert isinstance(opened, JwtDecoderGUI) + assert '"sub": "99"' in opened.output_edit.toPlainText() + gui.close() + gui.deleteLater() + + def test_open_without_a_token_is_noop(self, widget): + widget.input_edit.setPlainText(_RESPONSE) + widget.analyze() + assert widget.open_jwt_in_decoder() is None + + def test_button_goes_inactive_when_input_is_cleared(self, widget): + widget.input_edit.setPlainText(f"Authorization: Bearer {_jwt({'sub': '1'})}") + widget.analyze() + widget.input_edit.setPlainText(" ") + widget.analyze() + assert not widget.open_jwt_button.isEnabled() + + class TestEveryFindingCodeIsTranslated: def test_report_never_falls_back_to_a_code(self, app): # Any finding code without a language key would leak the slug into the UI. diff --git a/test/test_utils/test_response_inspector_gui.py b/test/test_utils/test_response_inspector_gui.py index 0f5ed97..a8cd29b 100644 --- a/test/test_utils/test_response_inspector_gui.py +++ b/test/test_utils/test_response_inspector_gui.py @@ -201,3 +201,40 @@ 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 assert window.tab_widget.added == [] + + +class TestResponseInspectorBodyHandOff: + def test_body_button_disabled_for_a_non_json_body(self, widget_with_window): + gui, _window = widget_with_window + gui.input_edit.setPlainText("HTTP/1.1 200 OK\n\nplain text body") + gui.analyze() + assert not gui.open_body_button.isEnabled() + + def test_body_button_enabled_for_a_json_body(self, widget_with_window): + gui, _window = widget_with_window + gui.input_edit.setPlainText('HTTP/1.1 200 OK\n\n{"a": 1}') + gui.analyze() + assert gui.open_body_button.isEnabled() + + def test_open_body_opens_prefilled_json_format(self, widget_with_window): + from pybreeze.pybreeze_ui.tools_gui.json_format_gui import JsonFormatGUI + gui, window = widget_with_window + gui.input_edit.setPlainText('HTTP/1.1 200 OK\n\n{"a":[1,2],"b":"x"}') + gui.analyze() + gui.open_body_in_json_format() + opened = window.tab_widget.added[0][0] + assert isinstance(opened, JsonFormatGUI) + formatted = opened.output_edit.toPlainText() + assert '"a": [' in formatted # arrived already pretty-printed + assert '"b": "x"' in formatted + + def test_open_body_without_json_is_noop(self, widget_with_window): + gui, window = widget_with_window + gui.input_edit.setPlainText("HTTP/1.1 500 Server Error\n\nboom") + gui.analyze() + assert gui.open_body_in_json_format() is None + assert window.tab_widget.added == [] + + 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 diff --git a/test/test_utils/test_tools_menu_docks.py b/test/test_utils/test_tools_menu_docks.py index cccce89..2d87023 100644 --- a/test/test_utils/test_tools_menu_docks.py +++ b/test/test_utils/test_tools_menu_docks.py @@ -14,6 +14,7 @@ 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 +from pybreeze.pybreeze_ui.tools_gui.har_import_gui import HarImportGUI from pybreeze.pybreeze_ui.tools_gui.hash_gui import HashGUI from pybreeze.pybreeze_ui.tools_gui.header_analyzer_gui import HeaderAnalyzerGUI from pybreeze.pybreeze_ui.tools_gui.http_status_gui import HttpStatusGUI @@ -37,6 +38,7 @@ def app(): "widget_type,widget_class", [ ("CurlImport", CurlImportGUI), + ("HarImport", HarImportGUI), ("JwtDecoder", JwtDecoderGUI), ("Timestamp", TimestampGUI), ("Hash", HashGUI),