From eb24ecfa7d42ee6660fb8a6b90179cb525a76f4c Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:31:10 +0000 Subject: [PATCH 1/7] feat: sdk changes - v4.74.4-rc.1 release prep --- .github/scripts/bump_revengai.py | 2 +- pyproject.toml | 2 +- reai_toolkit/app/core/shared_schema.py | 10 +++++ .../services/ai_decomp/ai_decomp_service.py | 3 +- reai_toolkit/app/services/chat/schema.py | 1 + .../app/services/matching/matching_service.py | 3 +- tests/idalib/ai_decomp/test_smoke.py | 2 +- tests/unit/ai_decomp/test_sdk_schemas.py | 30 +++++++++++--- tests/unit/ai_decomp/test_service.py | 2 +- tests/unit/chat/test_sse_parser.py | 1 + tests/unit/matching/test_sdk_schemas.py | 39 ++++++++++++++++--- uv.lock | 8 ++-- 12 files changed, 81 insertions(+), 22 deletions(-) diff --git a/.github/scripts/bump_revengai.py b/.github/scripts/bump_revengai.py index 34430283..6e6e3b84 100644 --- a/.github/scripts/bump_revengai.py +++ b/.github/scripts/bump_revengai.py @@ -22,7 +22,7 @@ PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml" PYPI_URL = "https://pypi.org/pypi/revengai/json" -SDK_PIN_RE = re.compile(r'"revengai>=([\d.]+)"') +SDK_PIN_RE = re.compile(r'"revengai>=([\w.]+)"') def fetch_latest_pypi_version() -> str: diff --git a/pyproject.toml b/pyproject.toml index 7d63f887..4c6d64a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "requests>=2.32", "loguru>=0.7", "pydantic", - "revengai>=4.72.2", + "revengai>=4.74.4rc1", "libbs>=3.8.1", ] diff --git a/reai_toolkit/app/core/shared_schema.py b/reai_toolkit/app/core/shared_schema.py index 134a2899..21cfc085 100644 --- a/reai_toolkit/app/core/shared_schema.py +++ b/reai_toolkit/app/core/shared_schema.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Generic, List, Optional, TypeVar from pydantic import BaseModel @@ -43,3 +44,12 @@ class FunctionBoundary(BaseModel): class Symbols(BaseModel): base_address: int function_boundaries: List[FunctionBoundary] + + +class TaskStatus(str, Enum): + UNINITIALISED = "UNINITIALISED" + PENDING = "PENDING" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + UNKNOWN_DEFAULT_OPEN_API = "unknown_default_open_api" diff --git a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py index f81ceacb..43ffe6de 100644 --- a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py +++ b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py @@ -17,14 +17,13 @@ from revengai.models.get_tokens_response import GetTokensResponse from revengai.models.patch_comment_body import PatchCommentBody from revengai.models.summary_data import SummaryData -from revengai.models.task_status import TaskStatus from revengai.models.token import Token from revengai.models.upsert_overrides_input_body import UpsertOverridesInputBody from revengai.models.upsert_rating_input_body import UpsertRatingInputBody from revengai.models.workflow_progress import WorkflowProgress from reai_toolkit.app.core.netstore_service import SimpleNetStore -from reai_toolkit.app.core.shared_schema import GenericApiReturn +from reai_toolkit.app.core.shared_schema import GenericApiReturn, TaskStatus from reai_toolkit.app.core.utils import parse_exception from reai_toolkit.app.interfaces.thread_service import IThreadService from reai_toolkit.app.services.ai_decomp.attribution import ( diff --git a/reai_toolkit/app/services/chat/schema.py b/reai_toolkit/app/services/chat/schema.py index e6a93d9e..51199752 100644 --- a/reai_toolkit/app/services/chat/schema.py +++ b/reai_toolkit/app/services/chat/schema.py @@ -30,6 +30,7 @@ 17: "QUESTION_ASKED", 18: "USER_ANSWERED", 19: "TOOL_CALL_PROGRESS", + 20: "AGENT_RUN_UPDATE", } TERMINAL_EVENTS: frozenset[str] = frozenset( diff --git a/reai_toolkit/app/services/matching/matching_service.py b/reai_toolkit/app/services/matching/matching_service.py index 7d065ae6..f986f660 100644 --- a/reai_toolkit/app/services/matching/matching_service.py +++ b/reai_toolkit/app/services/matching/matching_service.py @@ -22,11 +22,10 @@ MatchFilters, ProgressMessage, StartMatchingForFunctionsInputBody, - TaskStatus, ) from reai_toolkit.app.core import SimpleNetStore -from reai_toolkit.app.core.shared_schema import GenericApiReturn +from reai_toolkit.app.core.shared_schema import GenericApiReturn, TaskStatus from reai_toolkit.app.interfaces.thread_service import IThreadService from reai_toolkit.app.services.matching.schema import ( BatchDoneEvent, diff --git a/tests/idalib/ai_decomp/test_smoke.py b/tests/idalib/ai_decomp/test_smoke.py index 6455bab3..6f31330b 100644 --- a/tests/idalib/ai_decomp/test_smoke.py +++ b/tests/idalib/ai_decomp/test_smoke.py @@ -8,10 +8,10 @@ from revengai.models.decompilation_data import DecompilationData from revengai.models.inline_comment import InlineComment from revengai.models.summary_data import SummaryData -from revengai.models.task_status import TaskStatus from revengai.models.get_tokens_response import GetTokensResponse from revengai.models.rendered_token import RenderedToken +from reai_toolkit.app.core.shared_schema import TaskStatus from reai_toolkit.app.services.ai_decomp import ai_decomp_service as svc_mod from reai_toolkit.app.services.ai_decomp.ai_decomp_service import AiDecompService diff --git a/tests/unit/ai_decomp/test_sdk_schemas.py b/tests/unit/ai_decomp/test_sdk_schemas.py index fd36d3ff..acb77600 100644 --- a/tests/unit/ai_decomp/test_sdk_schemas.py +++ b/tests/unit/ai_decomp/test_sdk_schemas.py @@ -4,6 +4,9 @@ import inspect +import pytest +from pydantic import ValidationError + from revengai import FunctionsAIDecompilationApi, FunctionsCoreApi from revengai.models.ai_decompilation_rating import AiDecompilationRating from revengai.models.comments_data import CommentsData @@ -17,13 +20,13 @@ from revengai.models.patch_comment_body import PatchCommentBody from revengai.models.rendered_token import RenderedToken from revengai.models.summary_data import SummaryData -from revengai.models.task_status import TaskStatus from revengai.models.token import Token from revengai.models.upsert_overrides_data import UpsertOverridesData from revengai.models.upsert_overrides_input_body import UpsertOverridesInputBody from revengai.models.upsert_rating_input_body import UpsertRatingInputBody from revengai.models.workflow_progress import WorkflowProgress +from reai_toolkit.app.core.shared_schema import TaskStatus from reai_toolkit.app.services.ai_decomp.stream import ( EVENT_TYPES_APPLIED, EVENT_TYPES_SUGGESTED, @@ -190,13 +193,30 @@ def test_task_status_enum_covers_state_machine(): assert {"UNINITIALISED", "PENDING", "RUNNING", "COMPLETED", "FAILED"} <= members -def test_workflow_progress_accepts_uppercase_status_values(): - for status in ("UNINITIALISED", "PENDING", "RUNNING", "COMPLETED", "FAILED"): - WorkflowProgress.model_construct( - status=status, step="x", step_index=0, steps_total=1, messages=[] +def _progress(status: str) -> dict: + return { + "messages": [], + "percent": 0, + "status": status, + "step": "x", + "step_index": 0, + "step_share": 0, + "steps_total": 1, + } + + +def test_workflow_progress_accepts_every_status_the_plugin_holds(): + for status in TaskStatus: + assert WorkflowProgress.model_validate(_progress(status.value)).status == ( + status.value ) +def test_workflow_progress_still_rejects_a_status_the_plugin_does_not_know(): + with pytest.raises(ValidationError): + WorkflowProgress.model_validate(_progress("HALTED")) + + def test_overrides_are_posted_as_token_objects_not_bare_strings(): body = UpsertOverridesInputBody.from_json( '{"overrides": {"@@V@@": {"value": "buf"}}}' diff --git a/tests/unit/ai_decomp/test_service.py b/tests/unit/ai_decomp/test_service.py index c7f75267..4b3bcb2f 100644 --- a/tests/unit/ai_decomp/test_service.py +++ b/tests/unit/ai_decomp/test_service.py @@ -13,12 +13,12 @@ from revengai.models.decompilation_data import DecompilationData from revengai.models.inline_comment import InlineComment from revengai.models.summary_data import SummaryData -from revengai.models.task_status import TaskStatus from revengai.models.get_tokens_response import GetTokensResponse from revengai.models.rendered_token import RenderedToken from revengai.models.token import Token from revengai.models.workflow_progress import WorkflowProgress +from reai_toolkit.app.core.shared_schema import TaskStatus from reai_toolkit.app.services.ai_decomp import ai_decomp_service as svc_mod from reai_toolkit.app.services.ai_decomp.ai_decomp_service import AiDecompService diff --git a/tests/unit/chat/test_sse_parser.py b/tests/unit/chat/test_sse_parser.py index bb4fdc44..3d792d81 100644 --- a/tests/unit/chat/test_sse_parser.py +++ b/tests/unit/chat/test_sse_parser.py @@ -219,6 +219,7 @@ def test_the_numeric_table_matches_the_persisted_backend_enum(): 17: "QUESTION_ASKED", 18: "USER_ANSWERED", 19: "TOOL_CALL_PROGRESS", + 20: "AGENT_RUN_UPDATE", } diff --git a/tests/unit/matching/test_sdk_schemas.py b/tests/unit/matching/test_sdk_schemas.py index 07aa5b9e..be267d2f 100644 --- a/tests/unit/matching/test_sdk_schemas.py +++ b/tests/unit/matching/test_sdk_schemas.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from revengai import ( BinarySearchResultBody, CollectionListItemBody, @@ -8,10 +11,11 @@ MatchFilters, StartMatchingForFunctionsInputBody, StartMatchingOutputBody, - TaskStatus, ) from revengai.models.matched_function import MatchedFunction +from reai_toolkit.app.core.shared_schema import TaskStatus + def test_function_match_fields(): assert {"function_id", "matched_functions", "confidences"} <= set( @@ -47,10 +51,35 @@ def test_start_matching_output_fields(): ) -def test_task_status_values(): - assert {"UNINITIALISED", "PENDING", "RUNNING", "COMPLETED", "FAILED"} <= { - s.value for s in TaskStatus - } +def test_every_task_status_the_plugin_holds_is_one_the_status_body_accepts(): + for status in TaskStatus: + body = GetMatchesStatusOutputBody.model_validate( + { + "messages": [], + "percent": 0, + "status": status.value, + "step": "x", + "step_index": 0, + "step_share": 0, + "steps_total": 1, + } + ) + assert body.status == status.value + + +def test_the_status_body_still_rejects_a_status_the_plugin_does_not_know(): + with pytest.raises(ValidationError): + GetMatchesStatusOutputBody.model_validate( + { + "messages": [], + "percent": 0, + "status": "HALTED", + "step": "x", + "step_index": 0, + "step_share": 0, + "steps_total": 1, + } + ) def test_matched_function_fields(): diff --git a/uv.lock b/uv.lock index 38feec74..7264753b 100644 --- a/uv.lock +++ b/uv.lock @@ -809,7 +809,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7" }, { name = "pydantic" }, { name = "requests", specifier = ">=2.32" }, - { name = "revengai", specifier = ">=4.72.2" }, + { name = "revengai", specifier = ">=4.74.4rc1" }, ] [package.metadata.requires-dev] @@ -844,7 +844,7 @@ wheels = [ [[package]] name = "revengai" -version = "4.72.2" +version = "4.74.4rc1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lazy-imports" }, @@ -853,9 +853,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/0c/f6841e1f8450c57350e081ffdfc3d65b432ba2430bc3254abdb4ba258722/revengai-4.72.2.tar.gz", hash = "sha256:e49fba4fc2ee664e8bc556fcabedd6a3e9c8be9f33ced430f2916e50356811d4", size = 561742, upload-time = "2026-10-12T07:43:42.726Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/05/2cf6bb2bd033a27427ab24513c4b60367317cf1d8cde319f41225cf69fa2/revengai-4.74.4rc1.tar.gz", hash = "sha256:9c700e368d95ef2e32f55092d92fbe9d2b5279766e6afe408885e09db329ca1f", size = 661104, upload-time = "2026-10-28T09:02:23.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/25/d5e9872cb3e2747dc265c8dca1219128edc9b438013e03efc62c2ac41b7d/revengai-4.72.2-py3-none-any.whl", hash = "sha256:4edd14ada08a185d124f303e926618a8192c8e519cd62d06bb7916eb1dd1afb2", size = 1496400, upload-time = "2026-10-12T07:43:41.044Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4f/35e1a69a876888d1432917873ffb2f68e797358db6f9f19c0c3a33ae0a8a/revengai-4.74.4rc1-py3-none-any.whl", hash = "sha256:0a38769dbfa32681d3bb53aa55a9f4b6506f7a09eaac0f03842469d4e41c8d77", size = 1733240, upload-time = "2026-10-28T09:02:22.017Z" }, ] [[package]] From 12359e15e1bca53a3c1f5bc0886752f0f6827d4b Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:32:08 +0000 Subject: [PATCH 2/7] fix: enthusiast sub check --- .../app/services/auth/auth_service.py | 18 ++++++ tests/unit/auth/test_service.py | 60 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/reai_toolkit/app/services/auth/auth_service.py b/reai_toolkit/app/services/auth/auth_service.py index 22039eef..7e870f3e 100644 --- a/reai_toolkit/app/services/auth/auth_service.py +++ b/reai_toolkit/app/services/auth/auth_service.py @@ -1,6 +1,8 @@ +import json from typing import Tuple from loguru import logger +from pydantic import ValidationError from revengai import ApiClient, ApiException, ConfigApi, Configuration, IAMUsersApi, User from reai_toolkit.app.core.config_service import ConfigService @@ -143,12 +145,28 @@ def get_me(self, force_refresh: bool = False) -> User | None: api_client.user_agent = self.sdk_config.user_agent try: self._user = IAMUsersApi(api_client).get_me() + except ValidationError: + self._user = self._get_me_unvalidated(api_client) except Exception as e: logger.error(f"RevEng.AI: Failed to fetch current user: {e}") self._user = None return self._user + @staticmethod + def _get_me_unvalidated(api_client: ApiClient) -> User | None: + try: + response = IAMUsersApi(api_client).get_me_without_preload_content() + if response.status != 200: + logger.error( + f"RevEng.AI: Failed to fetch current user: HTTP {response.status}" + ) + return None + return User.model_construct(**json.loads(response.data)) + except Exception as e: + logger.error(f"RevEng.AI: Failed to fetch current user: {e}") + return None + def is_enthusiast(self) -> bool: """ Return whether the authenticated user is on the Enthusiast tier. diff --git a/tests/unit/auth/test_service.py b/tests/unit/auth/test_service.py index 35c908e7..033a49f1 100644 --- a/tests/unit/auth/test_service.py +++ b/tests/unit/auth/test_service.py @@ -1,6 +1,8 @@ +import json from unittest.mock import MagicMock import pytest +from pydantic import ValidationError from revengai import ApiException from reai_toolkit.app.services.auth import auth_service as svc_mod @@ -162,6 +164,64 @@ def test_is_enthusiast_false_when_user_unavailable(service, iam_api): assert service.is_enthusiast() is False +def _deployment_payload(tier: str) -> dict: + return { + "user_id": 1, + "email": "a@b.c", + "role": "USER", + "tier": tier, + "created_at": "2026-10-28T09:00:00Z", + "profile": {"first_name": "A", "last_name": "B"}, + } + + +def _raw_response(payload: dict, status: int = 200) -> MagicMock: + response = MagicMock() + response.status = status + response.data = json.dumps(payload).encode() + return response + + +def test_get_me_falls_back_when_the_model_wants_a_field_the_api_omits( + service, iam_api +): + payload = _deployment_payload("ENTHUSIAST") + iam_api.get_me.side_effect = ValidationError.from_exception_data("User", []) + iam_api.get_me_without_preload_content.return_value = _raw_response(payload) + + user = service.get_me() + + assert user is not None + assert user.tier == "ENTHUSIAST" + + +def test_the_tier_gate_still_applies_when_the_model_wants_a_field_the_api_omits( + service, iam_api +): + iam_api.get_me.side_effect = ValidationError.from_exception_data("User", []) + iam_api.get_me_without_preload_content.return_value = _raw_response( + _deployment_payload("ENTHUSIAST") + ) + + assert service.is_enthusiast() is True + + +def test_the_unvalidated_reread_still_reports_an_http_error_as_no_user( + service, iam_api +): + iam_api.get_me.side_effect = ValidationError.from_exception_data("User", []) + iam_api.get_me_without_preload_content.return_value = _raw_response({}, status=401) + + assert service.get_me() is None + + +def test_a_real_user_payload_never_takes_the_unvalidated_path(service, iam_api): + iam_api.get_me.return_value = _user("REVERSER") + + assert service.get_me().tier == "REVERSER" + iam_api.get_me_without_preload_content.assert_not_called() + + def test_verify_success_warms_user_cache(service, config_api, iam_api): config_api.v3_get_config.return_value = MagicMock() iam_api.get_me.return_value = _user("ENTHUSIAST") From f1a42cd2a23866aadada1a4f10b47f8ffcaeb475 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:59:59 +0000 Subject: [PATCH 3/7] fix: ida version support --- README.md | 3 ++- ida-plugin.json | 2 +- reai_toolkit/app/interfaces/base_service.py | 7 +++++++ tests/idalib/core/test_base_service.py | 8 ++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb42c3c3..82233d10 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ The builds for latest stable version of the RevEng.AI IDA plugin can be download ### Supported IDA versions -We support the following IDA versions and platforms: `8.5`, `9.0`, `9.1`, `9.2` and Python version from `3.10` to `3.14`. +We support the following IDA versions and platforms: `9.0`, `9.1`, `9.2`, `9.3`, `9.4`, `9.5` +and Python version from `3.10` to `3.14`. CI runs the suite against one build of each. ### Requirements The package `PyQt5` must be installed. diff --git a/ida-plugin.json b/ida-plugin.json index 07dadd5c..7f44b341 100644 --- a/ida-plugin.json +++ b/ida-plugin.json @@ -5,7 +5,7 @@ "entryPoint": "reai_toolkit_entry.py", "categories": ["decompilation", "integration-with-third-parties-interoperability", "malware-analysis", "other"], "logoPath": "reai_toolkit/app/components/resources/reveng_ai_logo.jpg", - "idaVersions": ">=8.5", + "idaVersions": ">=9.0", "description": "The RevEng.AI plugin integrates AI-powered code analysis and decompilation features into IDA Pro, enhancing reverse engineering workflows with advanced machine learning capabilities.", "version": "3.0.0" } diff --git a/reai_toolkit/app/interfaces/base_service.py b/reai_toolkit/app/interfaces/base_service.py index ea62ddf3..6aff7792 100644 --- a/reai_toolkit/app/interfaces/base_service.py +++ b/reai_toolkit/app/interfaces/base_service.py @@ -66,6 +66,13 @@ def update_function_name(ea: int, new_name: str, check_user_flags: bool = False) # If is_uname isn't available/behaves oddly, treat unknown as not user pass + # IDA 9.5 accepts non-identifier names through SN_CHECK where 9.3 and + # earlier rejected them, so validity is asserted here instead. Without + # this the caller never learns the name needs canonicalising and writes + # it into the IDB verbatim. + if not ida_name.is_ident(new_name): + return False + # Prefer setting sync names as AUTO so users can easily override them later. # https://python.docs.hex-rays.com/ida_name/index.html#ida_name.set_name # SN_CHECK: check for validity diff --git a/tests/idalib/core/test_base_service.py b/tests/idalib/core/test_base_service.py index d72e9276..25ce9225 100644 --- a/tests/idalib/core/test_base_service.py +++ b/tests/idalib/core/test_base_service.py @@ -29,6 +29,14 @@ def test_update_function_name_idempotent_when_unchanged(add_func): assert BaseService.update_function_name(add_func, "reai_same") is True +def test_update_function_name_rejects_non_identifier_names(add_func): + before = ida_name.get_name(add_func) + + assert BaseService.update_function_name(add_func, "bad::name!!") is False + assert BaseService.update_function_name(add_func, "bad name") is False + assert ida_name.get_name(add_func) == before + + def test_update_function_name_respects_user_names(add_func): ida_name.set_name(add_func, "user_picked", ida_name.SN_CHECK) From b5e2611d4e759908c6be104267e34fde78cc34fc Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:01:47 +0000 Subject: [PATCH 4/7] chore: test logs --- tests/ui/chat_ui_runner.py | 2 +- tests/ui/test_gui_ai_decomp.py | 5 ++++- tests/ui/test_gui_chat.py | 5 ++++- tests/ui/test_gui_sync.py | 5 ++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/ui/chat_ui_runner.py b/tests/ui/chat_ui_runner.py index d70a0b2b..a9f82ba4 100644 --- a/tests/ui/chat_ui_runner.py +++ b/tests/ui/chat_ui_runner.py @@ -55,7 +55,7 @@ def pump_until(predicate, timeout: float = 10.0) -> bool: return predicate() panel = ChatPanel(on_closed=lambda: None) - panel.Create(ChatPanel.TITLE) + report["create_returned"] = panel.Create(ChatPanel.TITLE) pump() if panel._transcript is None or panel._input is None: diff --git a/tests/ui/test_gui_ai_decomp.py b/tests/ui/test_gui_ai_decomp.py index 3ef225f5..4020f1cb 100644 --- a/tests/ui/test_gui_ai_decomp.py +++ b/tests/ui/test_gui_ai_decomp.py @@ -111,7 +111,10 @@ def test_ai_decomp_editing_flow_in_gui(tmp_path): report = json.loads(report_path.read_text()) - assert report["errors"] == [], report["errors"] + assert report["errors"] == [], ( + f"{report['errors']}" + f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}" + ) assert report["ok"] is True for check in CHECKS: assert report.get(check) is True, f"{check} failed: {report}" diff --git a/tests/ui/test_gui_chat.py b/tests/ui/test_gui_chat.py index b5f29f16..27e0cb09 100644 --- a/tests/ui/test_gui_chat.py +++ b/tests/ui/test_gui_chat.py @@ -105,7 +105,10 @@ def test_agent_chat_panel_in_gui(tmp_path): report = json.loads(report_path.read_text()) - assert report["errors"] == [], report["errors"] + assert report["errors"] == [], ( + f"{report['errors']}" + f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}" + ) assert report["ok"] is True for check in CHECKS: assert report.get(check) is True, f"{check} failed: {report}" diff --git a/tests/ui/test_gui_sync.py b/tests/ui/test_gui_sync.py index f70e9bc8..8229d75d 100644 --- a/tests/ui/test_gui_sync.py +++ b/tests/ui/test_gui_sync.py @@ -80,7 +80,10 @@ def test_sync_never_opens_or_switches_pseudocode(tmp_path): report = json.loads(report_path.read_text()) - assert report["errors"] == [] + assert report["errors"] == [], ( + f"{report['errors']}" + f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}" + ) assert report["ok"] is True assert report["target_count"] > 0 assert report["import_failed_ids"] == [] From a37dc23f11313856d96eb6b781bb71536063b02b Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:23:28 +0000 Subject: [PATCH 5/7] fix: qt5 gui test --- tests/ui/test_gui_ai_decomp.py | 1 + tests/ui/test_gui_chat.py | 1 + tests/ui/test_gui_sync.py | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/ui/test_gui_ai_decomp.py b/tests/ui/test_gui_ai_decomp.py index 4020f1cb..85eab62a 100644 --- a/tests/ui/test_gui_ai_decomp.py +++ b/tests/ui/test_gui_ai_decomp.py @@ -113,6 +113,7 @@ def test_ai_decomp_editing_flow_in_gui(tmp_path): assert report["errors"] == [], ( f"{report['errors']}" + f"\nreport: {report}" f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}" ) assert report["ok"] is True diff --git a/tests/ui/test_gui_chat.py b/tests/ui/test_gui_chat.py index 27e0cb09..a0b3f5a6 100644 --- a/tests/ui/test_gui_chat.py +++ b/tests/ui/test_gui_chat.py @@ -107,6 +107,7 @@ def test_agent_chat_panel_in_gui(tmp_path): assert report["errors"] == [], ( f"{report['errors']}" + f"\nreport: {report}" f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}" ) assert report["ok"] is True diff --git a/tests/ui/test_gui_sync.py b/tests/ui/test_gui_sync.py index 8229d75d..1e0ff04f 100644 --- a/tests/ui/test_gui_sync.py +++ b/tests/ui/test_gui_sync.py @@ -82,6 +82,7 @@ def test_sync_never_opens_or_switches_pseudocode(tmp_path): assert report["errors"] == [], ( f"{report['errors']}" + f"\nreport: {report}" f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}" ) assert report["ok"] is True From 86bff705d0d61814a69b99bb037841837e168aaf Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:24:31 +0000 Subject: [PATCH 6/7] fix: idausr conftest fix --- tests/ui/test_gui_ai_decomp.py | 12 ++++++++++++ tests/ui/test_gui_chat.py | 12 ++++++++++++ tests/ui/test_gui_sync.py | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/tests/ui/test_gui_ai_decomp.py b/tests/ui/test_gui_ai_decomp.py index 85eab62a..b8d73788 100644 --- a/tests/ui/test_gui_ai_decomp.py +++ b/tests/ui/test_gui_ai_decomp.py @@ -64,6 +64,17 @@ def _headless_env() -> dict[str, str]: return {"QT_QPA_PLATFORM": "offscreen"} return {} +def _isolated_idausr(tmp_path) -> dict[str, str]: + # conftest points IDAUSR at one directory shared with the idalib session in + # this process. The GUI IDA spawned below must not share it: with the same + # IDAUSR it starts but never creates its windows, so OnCreate never runs. + usr = tmp_path / "idausr" + usr.mkdir(exist_ok=True) + reg = os.path.join(os.environ.get("IDADIR", ""), "ida.reg") + if os.path.isfile(reg): + shutil.copy(reg, usr / "ida.reg") + return {"IDAUSR": str(usr)} + @pytest.mark.skipif( os.environ.get("REAI_UI_TESTS") != "1", @@ -87,6 +98,7 @@ def test_ai_decomp_editing_flow_in_gui(tmp_path): REAI_UI_REPORT=str(report_path), REAI_UI_ROOT=ROOT, **_headless_env(), + **_isolated_idausr(tmp_path), ) proc = subprocess.run( diff --git a/tests/ui/test_gui_chat.py b/tests/ui/test_gui_chat.py index a0b3f5a6..d2baf433 100644 --- a/tests/ui/test_gui_chat.py +++ b/tests/ui/test_gui_chat.py @@ -58,6 +58,17 @@ def _headless_env() -> dict[str, str]: return {"QT_QPA_PLATFORM": "offscreen"} return {} +def _isolated_idausr(tmp_path) -> dict[str, str]: + # conftest points IDAUSR at one directory shared with the idalib session in + # this process. The GUI IDA spawned below must not share it: with the same + # IDAUSR it starts but never creates its windows, so OnCreate never runs. + usr = tmp_path / "idausr" + usr.mkdir(exist_ok=True) + reg = os.path.join(os.environ.get("IDADIR", ""), "ida.reg") + if os.path.isfile(reg): + shutil.copy(reg, usr / "ida.reg") + return {"IDAUSR": str(usr)} + @pytest.mark.skipif( os.environ.get("REAI_UI_TESTS") != "1", @@ -81,6 +92,7 @@ def test_agent_chat_panel_in_gui(tmp_path): REAI_UI_REPORT=str(report_path), REAI_UI_ROOT=ROOT, **_headless_env(), + **_isolated_idausr(tmp_path), ) proc = subprocess.run( diff --git a/tests/ui/test_gui_sync.py b/tests/ui/test_gui_sync.py index 1e0ff04f..c036e7d4 100644 --- a/tests/ui/test_gui_sync.py +++ b/tests/ui/test_gui_sync.py @@ -33,6 +33,17 @@ def _headless_env() -> dict[str, str]: return {"QT_QPA_PLATFORM": "offscreen"} return {} +def _isolated_idausr(tmp_path) -> dict[str, str]: + # conftest points IDAUSR at one directory shared with the idalib session in + # this process. The GUI IDA spawned below must not share it: with the same + # IDAUSR it starts but never creates its windows, so OnCreate never runs. + usr = tmp_path / "idausr" + usr.mkdir(exist_ok=True) + reg = os.path.join(os.environ.get("IDADIR", ""), "ida.reg") + if os.path.isfile(reg): + shutil.copy(reg, usr / "ida.reg") + return {"IDAUSR": str(usr)} + @pytest.mark.skipif( os.environ.get("REAI_UI_TESTS") != "1", @@ -56,6 +67,7 @@ def test_sync_never_opens_or_switches_pseudocode(tmp_path): REAI_UI_REPORT=str(report_path), REAI_UI_ROOT=ROOT, **_headless_env(), + **_isolated_idausr(tmp_path), ) proc = subprocess.run( From 68f99bdfcfb88145b4111284990cc8f67bc6553e Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:46:48 +0000 Subject: [PATCH 7/7] fix: resolve ida path over ven path --- tests/ui/ai_decomp_ui_runner.py | 2 ++ tests/ui/test_gui_ai_decomp.py | 31 +++++++++++++++++++++++++++++-- tests/ui/test_gui_chat.py | 19 +++++++++++++++++-- tests/ui/test_gui_sync.py | 19 +++++++++++++++++-- 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/tests/ui/ai_decomp_ui_runner.py b/tests/ui/ai_decomp_ui_runner.py index b28ad0b7..d875b1a6 100644 --- a/tests/ui/ai_decomp_ui_runner.py +++ b/tests/ui/ai_decomp_ui_runner.py @@ -342,6 +342,7 @@ def repaint(target=None): report["diag_expected"] = expected report["diag_painted"] = probe.painted + report["rendering_hook_delivered"] = probe.calls > 0 report["rendering_hook_runs_on_repaint"] = probe.calls > 0 report["rendering_hook_sees_the_disassembly"] = bool(probe.widget_types) and set( probe.widget_types @@ -389,6 +390,7 @@ def main() -> None: "a_line_with_no_counterpart_lights_nothing": False, "disassembly_ea_lights_its_decomp_line": False, "an_unattributed_address_lights_nothing": False, + "rendering_hook_delivered": False, "rendering_hook_runs_on_repaint": False, "rendering_hook_sees_the_disassembly": False, "attributed_addresses_paint": False, diff --git a/tests/ui/test_gui_ai_decomp.py b/tests/ui/test_gui_ai_decomp.py index b8d73788..b75fd05c 100644 --- a/tests/ui/test_gui_ai_decomp.py +++ b/tests/ui/test_gui_ai_decomp.py @@ -12,6 +12,15 @@ RUNNER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ai_decomp_ui_runner.py") HELLO_ELF = os.path.join(ROOT, "tests", "fixtures", "hello.elf") +# IDA 9.0/9.1 never invoke get_lines_rendering_info under -A, so these only +# apply where the host delivers the callback at all (see rendering_hook_delivered). +REPAINT_CHECKS = { + "rendering_hook_runs_on_repaint", + "rendering_hook_sees_the_disassembly", + "attributed_addresses_paint", + "unattributed_lines_are_left_alone", +} + CHECKS = [ "view_created", "editor_read_only", @@ -75,6 +84,21 @@ def _isolated_idausr(tmp_path) -> dict[str, str]: shutil.copy(reg, usr / "ida.reg") return {"IDAUSR": str(usr)} +def _ida_path_without_venv(env: dict[str, str]) -> dict[str, str]: + # IDA's embedded Python resolves its site-packages from the first python3 + # on PATH. Under `uv run` that is the project venv, which carries PySide6 as + # a dev dependency, so IDA 9.0/9.1 load Qt6 bindings into a Qt5 application + # and PluginForm never builds its widgets. Drop the venv so IDA uses the Qt + # binding it ships with. + venv = env.get("VIRTUAL_ENV") + if not venv: + return env + env = dict(env) + env["PATH"] = os.pathsep.join( + p for p in env.get("PATH", "").split(os.pathsep) if p and not p.startswith(venv) + ) + return env + @pytest.mark.skipif( os.environ.get("REAI_UI_TESTS") != "1", @@ -93,13 +117,13 @@ def test_ai_decomp_editing_flow_in_gui(tmp_path): shutil.copy(HELLO_ELF, binary) report_path = tmp_path / "report.json" log_path = tmp_path / "ida.log" - env = dict( + env = _ida_path_without_venv(dict( os.environ, REAI_UI_REPORT=str(report_path), REAI_UI_ROOT=ROOT, **_headless_env(), **_isolated_idausr(tmp_path), - ) + )) proc = subprocess.run( [ida, "-A", f"-S{RUNNER}", f"-L{log_path}", str(binary)], @@ -129,5 +153,8 @@ def test_ai_decomp_editing_flow_in_gui(tmp_path): f"\nida log tail:\n{log_path.read_text()[-3000:] if log_path.is_file() else '(no log)'}" ) assert report["ok"] is True + delivered = report.get("rendering_hook_delivered", False) for check in CHECKS: + if check in REPAINT_CHECKS and not delivered: + continue assert report.get(check) is True, f"{check} failed: {report}" diff --git a/tests/ui/test_gui_chat.py b/tests/ui/test_gui_chat.py index d2baf433..20f9292b 100644 --- a/tests/ui/test_gui_chat.py +++ b/tests/ui/test_gui_chat.py @@ -69,6 +69,21 @@ def _isolated_idausr(tmp_path) -> dict[str, str]: shutil.copy(reg, usr / "ida.reg") return {"IDAUSR": str(usr)} +def _ida_path_without_venv(env: dict[str, str]) -> dict[str, str]: + # IDA's embedded Python resolves its site-packages from the first python3 + # on PATH. Under `uv run` that is the project venv, which carries PySide6 as + # a dev dependency, so IDA 9.0/9.1 load Qt6 bindings into a Qt5 application + # and PluginForm never builds its widgets. Drop the venv so IDA uses the Qt + # binding it ships with. + venv = env.get("VIRTUAL_ENV") + if not venv: + return env + env = dict(env) + env["PATH"] = os.pathsep.join( + p for p in env.get("PATH", "").split(os.pathsep) if p and not p.startswith(venv) + ) + return env + @pytest.mark.skipif( os.environ.get("REAI_UI_TESTS") != "1", @@ -87,13 +102,13 @@ def test_agent_chat_panel_in_gui(tmp_path): shutil.copy(HELLO_ELF, binary) report_path = tmp_path / "report.json" log_path = tmp_path / "ida.log" - env = dict( + env = _ida_path_without_venv(dict( os.environ, REAI_UI_REPORT=str(report_path), REAI_UI_ROOT=ROOT, **_headless_env(), **_isolated_idausr(tmp_path), - ) + )) proc = subprocess.run( [ida, "-A", f"-S{RUNNER}", f"-L{log_path}", str(binary)], diff --git a/tests/ui/test_gui_sync.py b/tests/ui/test_gui_sync.py index c036e7d4..4ca8cefd 100644 --- a/tests/ui/test_gui_sync.py +++ b/tests/ui/test_gui_sync.py @@ -44,6 +44,21 @@ def _isolated_idausr(tmp_path) -> dict[str, str]: shutil.copy(reg, usr / "ida.reg") return {"IDAUSR": str(usr)} +def _ida_path_without_venv(env: dict[str, str]) -> dict[str, str]: + # IDA's embedded Python resolves its site-packages from the first python3 + # on PATH. Under `uv run` that is the project venv, which carries PySide6 as + # a dev dependency, so IDA 9.0/9.1 load Qt6 bindings into a Qt5 application + # and PluginForm never builds its widgets. Drop the venv so IDA uses the Qt + # binding it ships with. + venv = env.get("VIRTUAL_ENV") + if not venv: + return env + env = dict(env) + env["PATH"] = os.pathsep.join( + p for p in env.get("PATH", "").split(os.pathsep) if p and not p.startswith(venv) + ) + return env + @pytest.mark.skipif( os.environ.get("REAI_UI_TESTS") != "1", @@ -62,13 +77,13 @@ def test_sync_never_opens_or_switches_pseudocode(tmp_path): shutil.copy(HELLO_ELF, binary) report_path = tmp_path / "report.json" log_path = tmp_path / "ida.log" - env = dict( + env = _ida_path_without_venv(dict( os.environ, REAI_UI_REPORT=str(report_path), REAI_UI_ROOT=ROOT, **_headless_env(), **_isolated_idausr(tmp_path), - ) + )) proc = subprocess.run( [ida, "-A", f"-S{RUNNER}", f"-L{log_path}", str(binary)],