axterminator 0.1.0

World's most superior macOS GUI testing framework with background testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
"""
Tests for AXTerminator self-healing element location system.

Tests cover:
- Healing by data-testid
- Healing by aria-label
- Healing by identifier
- Healing by title
- Healing by xpath
- Healing by position
- Healing configuration
- Timeout budget management
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
    from conftest import MockAXElement, TestApp


class TestHealingByDataTestId:
    """Tests for healing using data-testid attribute."""

    def test_healing_prefers_data_testid(
        self, mock_calculator_tree: MockAXElement
    ) -> None:
        """data-testid is the first (most stable) healing strategy."""
        # In real implementation, data-testid would be the primary locator
        tree = mock_calculator_tree

        # Simulate element with data-testid
        def find_by_data_testid(
            node: MockAXElement, testid: str
        ) -> MockAXElement | None:
            if node.data_testid == testid:
                return node
            for child in node.get_children():
                result = find_by_data_testid(child, testid)
                if result:
                    return result
            return None

        # Add data-testid to a button
        tree.get_children()[0].get_children()[1].get_children()[
            5
        ].data_testid = "submit-button"

        button = find_by_data_testid(tree, "submit-button")

        assert button is not None
        assert button.title == "5"

    def test_data_testid_is_default_first_strategy(self) -> None:
        """data_testid should be first in default strategy order."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.strategies[0] == "data_testid"

    def test_data_testid_stable_across_ui_changes(self) -> None:
        """data-testid should be stable even when UI structure changes."""
        # This is a conceptual test - data-testid doesn't change
        # when elements move in the tree, unlike xpath or position
        pass


class TestHealingByAriaLabel:
    """Tests for healing using aria-label attribute."""

    def test_healing_by_aria_label(self, mock_calculator_tree: MockAXElement) -> None:
        """aria-label is second healing strategy."""
        tree = mock_calculator_tree

        def find_by_aria_label(node: MockAXElement, label: str) -> MockAXElement | None:
            if node.aria_label == label:
                return node
            for child in node.get_children():
                result = find_by_aria_label(child, label)
                if result:
                    return result
            return None

        # Add aria-label to a button
        tree.get_children()[0].get_children()[1].get_children()[
            7
        ].aria_label = "Clear all entries"

        button = find_by_aria_label(tree, "Clear all entries")

        assert button is not None

    def test_aria_label_is_second_strategy(self) -> None:
        """aria_label should be second in default strategy order."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.strategies[1] == "aria_label"

    def test_aria_label_for_accessibility(self) -> None:
        """aria-label is commonly used for screen reader descriptions."""
        # Elements often have aria-label for accessibility
        # which makes it a good fallback
        pass


class TestHealingByIdentifier:
    """Tests for healing using accessibility identifier."""

    def test_healing_by_identifier(self, mock_calculator_tree: MockAXElement) -> None:
        """identifier is third healing strategy."""
        tree = mock_calculator_tree

        def find_by_identifier(
            node: MockAXElement, identifier: str
        ) -> MockAXElement | None:
            if node.identifier == identifier:
                return node
            for child in node.get_children():
                result = find_by_identifier(child, identifier)
                if result:
                    return result
            return None

        button = find_by_identifier(tree, "calc_btn_5")

        assert button is not None
        assert button.title == "5"

    def test_identifier_is_third_strategy(self) -> None:
        """identifier should be third in default strategy order."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.strategies[2] == "identifier"

    @pytest.mark.requires_app
    def test_native_apps_have_identifiers(self, calculator_app: TestApp) -> None:
        """Native macOS apps typically have AXIdentifier."""
        import axterminator as ax

        app = ax.app(name="Calculator")

        try:
            element = app.find("1")
            identifier = element.identifier()

            # Native apps usually have identifiers
            # though they may be auto-generated
            assert identifier is None or isinstance(identifier, str)
        except RuntimeError:
            pass


class TestHealingByTitle:
    """Tests for healing using element title."""

    def test_healing_by_title(self, mock_calculator_tree: MockAXElement) -> None:
        """title is fourth healing strategy."""
        tree = mock_calculator_tree

        def find_by_title(node: MockAXElement, title: str) -> MockAXElement | None:
            if node.title == title:
                return node
            for child in node.get_children():
                result = find_by_title(child, title)
                if result:
                    return result
            return None

        button = find_by_title(tree, "AC")

        assert button is not None

    def test_title_is_fourth_strategy(self) -> None:
        """title should be fourth in default strategy order."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.strategies[3] == "title"

    def test_title_may_change_with_localization(self) -> None:
        """Title can change with language settings."""
        # This is why title is lower priority than identifier
        pass


class TestHealingByXPath:
    """Tests for healing using structural XPath-like path."""

    def test_healing_by_xpath(self, mock_calculator_tree: MockAXElement) -> None:
        """xpath is fifth healing strategy."""
        tree = mock_calculator_tree

        # XPath example: //AXWindow/AXGroup/AXButton[@title='5']
        def find_by_path(
            node: MockAXElement,
            path_parts: list[tuple[str, dict[str, str] | None]],
            index: int = 0,
        ) -> MockAXElement | None:
            if index >= len(path_parts):
                return None

            role, attrs = path_parts[index]

            # Check if current node matches
            if node.role != role:
                return None

            if attrs:
                for key, value in attrs.items():
                    if getattr(node, key, None) != value:
                        return None

            # If this is the last part, we found it
            if index == len(path_parts) - 1:
                return node

            # Search children for next part
            for child in node.get_children():
                result = find_by_path(child, path_parts, index + 1)
                if result:
                    return result

            return None

        # Search for: //AXWindow/AXGroup/AXButton[@title='5']
        path = [
            ("AXApplication", None),
            ("AXWindow", None),
            ("AXGroup", None),
            ("AXButton", {"title": "5"}),
        ]

        button = find_by_path(tree, path)

        assert button is not None
        assert button.title == "5"

    def test_xpath_is_fifth_strategy(self) -> None:
        """xpath should be fifth in default strategy order."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.strategies[4] == "xpath"

    def test_xpath_sensitive_to_structure_changes(self) -> None:
        """XPath can break when UI structure changes."""
        # This is why xpath is lower priority
        pass


class TestHealingByPosition:
    """Tests for healing using relative position."""

    def test_healing_by_position(self, mock_calculator_tree: MockAXElement) -> None:
        """position is sixth healing strategy."""
        tree = mock_calculator_tree

        # Find element closest to given position
        def find_by_position(
            node: MockAXElement,
            target_x: float,
            target_y: float,
            best: tuple[MockAXElement | None, float] = (None, float("inf")),
        ) -> tuple[MockAXElement | None, float]:
            if node.bounds:
                x, y, w, h = node.bounds
                center_x = x + w / 2
                center_y = y + h / 2
                distance = (
                    (center_x - target_x) ** 2 + (center_y - target_y) ** 2
                ) ** 0.5

                if distance < best[1]:
                    best = (node, distance)

            for child in node.get_children():
                best = find_by_position(child, target_x, target_y, best)

            return best

        # Add bounds to some elements
        tree.get_children()[0].get_children()[1].get_children()[5].bounds = (
            100,
            200,
            50,
            50,
        )

        element, distance = find_by_position(tree, 125, 225)

        assert element is not None

    def test_position_is_sixth_strategy(self) -> None:
        """position should be sixth in default strategy order."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.strategies[5] == "position"

    def test_position_fragile_on_resize(self) -> None:
        """Position-based finding is fragile when window resizes."""
        # This is why position is low priority
        pass


class TestHealingConfig:
    """Tests for healing configuration."""

    def test_default_config(self) -> None:
        """Default config has all strategies enabled."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert len(config.strategies) == 7
        assert config.max_heal_time_ms == 100
        assert config.cache_healed is True

    def test_custom_strategies(self) -> None:
        """Custom strategy list can be provided."""
        import axterminator as ax

        config = ax.HealingConfig(strategies=["data_testid", "identifier"])

        assert len(config.strategies) == 2
        assert "title" not in config.strategies

    def test_custom_timeout(self) -> None:
        """Custom timeout can be set."""
        import axterminator as ax

        config = ax.HealingConfig(max_heal_time_ms=500)

        assert config.max_heal_time_ms == 500

    def test_disable_caching(self) -> None:
        """Healing cache can be disabled."""
        import axterminator as ax

        config = ax.HealingConfig(cache_healed=False)

        assert config.cache_healed is False

    def test_configure_healing_global(self) -> None:
        """configure_healing sets global config."""
        import axterminator as ax

        config = ax.HealingConfig(max_heal_time_ms=200)

        # Should not raise
        ax.configure_healing(config)

    def test_strategy_order_matters(self) -> None:
        """Strategies are tried in order specified."""
        import axterminator as ax

        # Title-first config
        config = ax.HealingConfig(strategies=["title", "identifier", "data_testid"])

        assert config.strategies[0] == "title"

    def test_empty_strategies_list(self) -> None:
        """Empty strategies list disables healing."""
        import axterminator as ax

        config = ax.HealingConfig(strategies=[])

        assert len(config.strategies) == 0

    def test_config_strategies_getter(self) -> None:
        """strategies property is accessible."""
        import axterminator as ax

        config = ax.HealingConfig()

        strategies = config.strategies

        assert isinstance(strategies, list)
        assert all(isinstance(s, str) for s in strategies)

    def test_config_strategies_setter(self) -> None:
        """strategies can be modified after creation."""
        import axterminator as ax

        config = ax.HealingConfig()
        config.strategies = ["identifier", "title"]

        assert len(config.strategies) == 2

    def test_config_max_heal_time_getter(self) -> None:
        """max_heal_time_ms property is accessible."""
        import axterminator as ax

        config = ax.HealingConfig(max_heal_time_ms=250)

        assert config.max_heal_time_ms == 250

    def test_config_max_heal_time_setter(self) -> None:
        """max_heal_time_ms can be modified."""
        import axterminator as ax

        config = ax.HealingConfig()
        config.max_heal_time_ms = 300

        assert config.max_heal_time_ms == 300


class TestHealingTimeoutBudget:
    """Tests for healing timeout budget management."""

    def test_healing_respects_timeout(self) -> None:
        """Healing stops when timeout is reached."""
        import axterminator as ax

        config = ax.HealingConfig(max_heal_time_ms=50)

        # With a 50ms budget, not all strategies can be tried
        assert config.max_heal_time_ms == 50

    def test_budget_divided_among_strategies(self) -> None:
        """Time budget is managed across strategies."""
        import axterminator as ax

        config = ax.HealingConfig(max_heal_time_ms=100)

        # 7 strategies with 100ms budget = ~14ms per strategy
        per_strategy = config.max_heal_time_ms / len(config.strategies)

        assert per_strategy > 0

    @pytest.mark.slow
    def test_healing_stops_at_timeout(self) -> None:
        """Healing process respects timeout limit."""
        # This would need actual element healing to test properly
        pass

    def test_successful_heal_within_budget(self) -> None:
        """Successful heal returns before timeout."""
        import axterminator as ax

        config = ax.HealingConfig(max_heal_time_ms=5000)

        # If an element is found by first strategy,
        # it should return immediately, not wait 5 seconds
        assert config.max_heal_time_ms == 5000


class TestHealingStrategies:
    """Tests for individual healing strategy behavior."""

    def test_all_seven_strategies_exist(self) -> None:
        """All 7 healing strategies are defined."""
        import axterminator as ax

        config = ax.HealingConfig()

        expected = [
            "data_testid",
            "aria_label",
            "identifier",
            "title",
            "xpath",
            "position",
            "visual_vlm",
        ]

        for strategy in expected:
            assert strategy in config.strategies

    def test_visual_vlm_is_last_resort(self) -> None:
        """visual_vlm (VLM-based) is the last strategy."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.strategies[-1] == "visual_vlm"

    def test_strategies_are_strings(self) -> None:
        """Strategy names are strings."""
        import axterminator as ax

        config = ax.HealingConfig()

        for strategy in config.strategies:
            assert isinstance(strategy, str)

    def test_unknown_strategy_ignored(self) -> None:
        """Unknown strategy names don't cause crashes."""
        import axterminator as ax

        # Should not raise
        config = ax.HealingConfig(
            strategies=["data_testid", "unknown_strategy", "title"]
        )

        assert len(config.strategies) == 3


class TestHealingCache:
    """Tests for healing result caching."""

    def test_cache_enabled_by_default(self) -> None:
        """Caching is enabled by default."""
        import axterminator as ax

        config = ax.HealingConfig()

        assert config.cache_healed is True

    def test_cache_can_be_disabled(self) -> None:
        """Caching can be explicitly disabled."""
        import axterminator as ax

        config = ax.HealingConfig(cache_healed=False)

        assert config.cache_healed is False

    def test_cached_heals_are_faster(self) -> None:
        """Second lookup of healed element is faster."""
        # This would need actual implementation to test
        pass


class TestHealingFallback:
    """Tests for healing fallback behavior."""

    def test_fallback_to_next_strategy(self) -> None:
        """When one strategy fails, next is tried."""
        # Conceptual: if data_testid fails, try aria_label
        pass

    def test_all_strategies_exhausted(self) -> None:
        """Error when all strategies fail."""
        # Should raise ElementNotFoundAfterHealing
        pass

    def test_partial_budget_to_next_strategy(self) -> None:
        """Remaining time budget passes to next strategy."""
        # If data_testid takes 10ms of 100ms budget,
        # next strategy has 90ms remaining
        pass


class TestHealingPerformance:
    """Performance tests for healing system."""

    @pytest.mark.slow
    def test_healing_under_100ms(self) -> None:
        """Default healing completes under 100ms."""
        import axterminator as ax

        config = ax.HealingConfig()

        # Default budget is 100ms
        assert config.max_heal_time_ms == 100

    def test_first_strategy_fastest(self) -> None:
        """First matching strategy returns immediately."""
        import axterminator as ax

        config = ax.HealingConfig(max_heal_time_ms=1000)

        # Even with 1000ms budget, first match should be instant
        assert config.max_heal_time_ms == 1000


class TestHealingIntegration:
    """Integration tests for healing with real apps."""

    @pytest.mark.requires_app
    def test_healing_finds_moved_element(self, calculator_app: TestApp) -> None:
        """Healing finds element even if it moves in tree."""
        import axterminator as ax

        app = ax.app(name="Calculator")

        # Find element normally first
        try:
            element = app.find("5")
            assert element is not None

            # Element should still be findable
            element2 = app.find("5")
            assert element2 is not None
        except RuntimeError:
            pass

    @pytest.mark.requires_app
    def test_healing_with_custom_config(self, calculator_app: TestApp) -> None:
        """Custom healing config affects element finding."""
        import axterminator as ax

        # Configure healing with fewer strategies
        config = ax.HealingConfig(
            strategies=["title", "identifier"],
            max_heal_time_ms=50,
        )
        ax.configure_healing(config)

        app = ax.app(name="Calculator")

        try:
            element = app.find("5")
            assert element is not None
        except RuntimeError:
            # May not find if strategies insufficient
            pass

        # Restore default config
        ax.configure_healing(ax.HealingConfig())