sentienceapi 0.90.16__py3-none-any.whl → 0.98.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sentienceapi might be problematic. Click here for more details.

Files changed (90) hide show
  1. sentience/__init__.py +120 -6
  2. sentience/_extension_loader.py +156 -1
  3. sentience/action_executor.py +217 -0
  4. sentience/actions.py +758 -30
  5. sentience/agent.py +806 -293
  6. sentience/agent_config.py +3 -0
  7. sentience/agent_runtime.py +840 -0
  8. sentience/asserts/__init__.py +70 -0
  9. sentience/asserts/expect.py +621 -0
  10. sentience/asserts/query.py +383 -0
  11. sentience/async_api.py +89 -1141
  12. sentience/backends/__init__.py +137 -0
  13. sentience/backends/actions.py +372 -0
  14. sentience/backends/browser_use_adapter.py +241 -0
  15. sentience/backends/cdp_backend.py +393 -0
  16. sentience/backends/exceptions.py +211 -0
  17. sentience/backends/playwright_backend.py +194 -0
  18. sentience/backends/protocol.py +216 -0
  19. sentience/backends/sentience_context.py +469 -0
  20. sentience/backends/snapshot.py +483 -0
  21. sentience/base_agent.py +95 -0
  22. sentience/browser.py +678 -39
  23. sentience/browser_evaluator.py +299 -0
  24. sentience/canonicalization.py +207 -0
  25. sentience/cloud_tracing.py +507 -42
  26. sentience/constants.py +6 -0
  27. sentience/conversational_agent.py +77 -43
  28. sentience/cursor_policy.py +142 -0
  29. sentience/element_filter.py +136 -0
  30. sentience/expect.py +98 -2
  31. sentience/extension/background.js +56 -185
  32. sentience/extension/content.js +150 -287
  33. sentience/extension/injected_api.js +1088 -1368
  34. sentience/extension/manifest.json +1 -1
  35. sentience/extension/pkg/sentience_core.d.ts +22 -22
  36. sentience/extension/pkg/sentience_core.js +275 -433
  37. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  38. sentience/extension/release.json +47 -47
  39. sentience/failure_artifacts.py +241 -0
  40. sentience/formatting.py +9 -53
  41. sentience/inspector.py +183 -1
  42. sentience/integrations/__init__.py +6 -0
  43. sentience/integrations/langchain/__init__.py +12 -0
  44. sentience/integrations/langchain/context.py +18 -0
  45. sentience/integrations/langchain/core.py +326 -0
  46. sentience/integrations/langchain/tools.py +180 -0
  47. sentience/integrations/models.py +46 -0
  48. sentience/integrations/pydanticai/__init__.py +15 -0
  49. sentience/integrations/pydanticai/deps.py +20 -0
  50. sentience/integrations/pydanticai/toolset.py +468 -0
  51. sentience/llm_interaction_handler.py +191 -0
  52. sentience/llm_provider.py +765 -66
  53. sentience/llm_provider_utils.py +120 -0
  54. sentience/llm_response_builder.py +153 -0
  55. sentience/models.py +595 -3
  56. sentience/ordinal.py +280 -0
  57. sentience/overlay.py +109 -2
  58. sentience/protocols.py +228 -0
  59. sentience/query.py +67 -5
  60. sentience/read.py +95 -3
  61. sentience/recorder.py +223 -3
  62. sentience/schemas/trace_v1.json +128 -9
  63. sentience/screenshot.py +48 -2
  64. sentience/sentience_methods.py +86 -0
  65. sentience/snapshot.py +599 -55
  66. sentience/snapshot_diff.py +126 -0
  67. sentience/text_search.py +120 -5
  68. sentience/trace_event_builder.py +148 -0
  69. sentience/trace_file_manager.py +197 -0
  70. sentience/trace_indexing/index_schema.py +95 -7
  71. sentience/trace_indexing/indexer.py +105 -48
  72. sentience/tracer_factory.py +120 -9
  73. sentience/tracing.py +172 -8
  74. sentience/utils/__init__.py +40 -0
  75. sentience/utils/browser.py +46 -0
  76. sentience/{utils.py → utils/element.py} +3 -42
  77. sentience/utils/formatting.py +59 -0
  78. sentience/verification.py +618 -0
  79. sentience/visual_agent.py +2058 -0
  80. sentience/wait.py +68 -2
  81. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +199 -40
  82. sentienceapi-0.98.0.dist-info/RECORD +92 -0
  83. sentience/extension/test-content.js +0 -4
  84. sentienceapi-0.90.16.dist-info/RECORD +0 -50
  85. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
  86. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
  87. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
  88. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
  89. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
  90. {sentienceapi-0.90.16.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,618 @@
1
+ """
2
+ Verification primitives for agent assertion loops.
3
+
4
+ This module provides assertion predicates and outcome types for runtime verification
5
+ in agent loops. Assertions evaluate against the current browser state (snapshot/url)
6
+ and record results into the trace.
7
+
8
+ Key concepts:
9
+ - AssertOutcome: Result of evaluating an assertion
10
+ - AssertContext: Context provided to assertion predicates (snapshot, url, step_id)
11
+ - Predicate: Callable that takes context and returns outcome
12
+
13
+ Example usage:
14
+ from sentience.verification import url_matches, exists, AssertContext
15
+
16
+ # Create predicates
17
+ on_search_page = url_matches(r"/s\\?k=")
18
+ results_loaded = exists("text~'Results'")
19
+
20
+ # Evaluate against context
21
+ ctx = AssertContext(snapshot=snapshot, url="https://example.com/s?k=shoes")
22
+ outcome = on_search_page(ctx)
23
+ print(outcome.passed) # True
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from collections.abc import Callable
30
+ from dataclasses import dataclass, field
31
+ from typing import TYPE_CHECKING, Any
32
+
33
+ if TYPE_CHECKING:
34
+ from .models import Snapshot
35
+
36
+
37
+ @dataclass
38
+ class AssertOutcome:
39
+ """
40
+ Result of evaluating an assertion predicate.
41
+
42
+ Attributes:
43
+ passed: Whether the assertion passed
44
+ reason: Human-readable explanation (especially useful when failed)
45
+ details: Additional structured data for debugging/display
46
+ """
47
+
48
+ passed: bool
49
+ reason: str = ""
50
+ details: dict[str, Any] = field(default_factory=dict)
51
+
52
+
53
+ @dataclass
54
+ class AssertContext:
55
+ """
56
+ Context provided to assertion predicates.
57
+
58
+ Provides access to current browser state without requiring
59
+ the predicate to know about browser internals.
60
+
61
+ Attributes:
62
+ snapshot: Current page snapshot (may be None if not taken)
63
+ url: Current page URL
64
+ step_id: Current step identifier (for trace correlation)
65
+ """
66
+
67
+ snapshot: Snapshot | None = None
68
+ url: str | None = None
69
+ step_id: str | None = None
70
+
71
+
72
+ # Type alias for assertion predicates
73
+ Predicate = Callable[[AssertContext], AssertOutcome]
74
+
75
+
76
+ def url_matches(pattern: str) -> Predicate:
77
+ """
78
+ Create a predicate that checks if current URL matches a regex pattern.
79
+
80
+ Args:
81
+ pattern: Regular expression pattern to match against URL
82
+
83
+ Returns:
84
+ Predicate function that evaluates URL matching
85
+
86
+ Example:
87
+ >>> pred = url_matches(r"/search\\?q=")
88
+ >>> ctx = AssertContext(url="https://example.com/search?q=shoes")
89
+ >>> outcome = pred(ctx)
90
+ >>> outcome.passed
91
+ True
92
+ """
93
+ rx = re.compile(pattern)
94
+
95
+ def _pred(ctx: AssertContext) -> AssertOutcome:
96
+ url = ctx.url or ""
97
+ ok = rx.search(url) is not None
98
+ return AssertOutcome(
99
+ passed=ok,
100
+ reason="" if ok else f"url did not match pattern: {pattern}",
101
+ details={"pattern": pattern, "url": url[:200]},
102
+ )
103
+
104
+ return _pred
105
+
106
+
107
+ def url_contains(substring: str) -> Predicate:
108
+ """
109
+ Create a predicate that checks if current URL contains a substring.
110
+
111
+ Args:
112
+ substring: String to search for in URL
113
+
114
+ Returns:
115
+ Predicate function that evaluates URL containment
116
+
117
+ Example:
118
+ >>> pred = url_contains("/cart")
119
+ >>> ctx = AssertContext(url="https://example.com/cart/checkout")
120
+ >>> outcome = pred(ctx)
121
+ >>> outcome.passed
122
+ True
123
+ """
124
+
125
+ def _pred(ctx: AssertContext) -> AssertOutcome:
126
+ url = ctx.url or ""
127
+ ok = substring in url
128
+ return AssertOutcome(
129
+ passed=ok,
130
+ reason="" if ok else f"url does not contain: {substring}",
131
+ details={"substring": substring, "url": url[:200]},
132
+ )
133
+
134
+ return _pred
135
+
136
+
137
+ def exists(selector: str) -> Predicate:
138
+ """
139
+ Create a predicate that checks if elements matching selector exist.
140
+
141
+ Uses the SDK's query engine to find matching elements.
142
+
143
+ Args:
144
+ selector: Semantic selector string (e.g., "role=button text~'Sign in'")
145
+
146
+ Returns:
147
+ Predicate function that evaluates element existence
148
+
149
+ Example:
150
+ >>> pred = exists("text~'Results'")
151
+ >>> # Will check if snapshot contains elements with "Results" in text
152
+ """
153
+
154
+ def _pred(ctx: AssertContext) -> AssertOutcome:
155
+ snap = ctx.snapshot
156
+ if snap is None:
157
+ return AssertOutcome(
158
+ passed=False,
159
+ reason="no snapshot available",
160
+ details={"selector": selector, "reason_code": "no_snapshot"},
161
+ )
162
+
163
+ # Import here to avoid circular imports
164
+ from .query import query
165
+
166
+ matches = query(snap, selector)
167
+ ok = len(matches) > 0
168
+ return AssertOutcome(
169
+ passed=ok,
170
+ reason="" if ok else f"no elements matched selector: {selector}",
171
+ details={
172
+ "selector": selector,
173
+ "matched": len(matches),
174
+ "reason_code": "ok" if ok else "no_match",
175
+ },
176
+ )
177
+
178
+ return _pred
179
+
180
+
181
+ def not_exists(selector: str) -> Predicate:
182
+ """
183
+ Create a predicate that checks that NO elements match the selector.
184
+
185
+ Useful for asserting that error messages, loading spinners, etc. are gone.
186
+
187
+ Args:
188
+ selector: Semantic selector string
189
+
190
+ Returns:
191
+ Predicate function that evaluates element non-existence
192
+
193
+ Example:
194
+ >>> pred = not_exists("text~'Loading'")
195
+ >>> # Will pass if no elements contain "Loading" text
196
+ """
197
+
198
+ def _pred(ctx: AssertContext) -> AssertOutcome:
199
+ snap = ctx.snapshot
200
+ if snap is None:
201
+ return AssertOutcome(
202
+ passed=False,
203
+ reason="no snapshot available",
204
+ details={"selector": selector, "reason_code": "no_snapshot"},
205
+ )
206
+
207
+ from .query import query
208
+
209
+ matches = query(snap, selector)
210
+ ok = len(matches) == 0
211
+ return AssertOutcome(
212
+ passed=ok,
213
+ reason="" if ok else f"found {len(matches)} elements matching: {selector}",
214
+ details={
215
+ "selector": selector,
216
+ "matched": len(matches),
217
+ "reason_code": "ok" if ok else "unexpected_match",
218
+ },
219
+ )
220
+
221
+ return _pred
222
+
223
+
224
+ def element_count(selector: str, *, min_count: int = 0, max_count: int | None = None) -> Predicate:
225
+ """
226
+ Create a predicate that checks the number of matching elements.
227
+
228
+ Args:
229
+ selector: Semantic selector string
230
+ min_count: Minimum number of matches required (inclusive)
231
+ max_count: Maximum number of matches allowed (inclusive, None = no limit)
232
+
233
+ Returns:
234
+ Predicate function that evaluates element count
235
+
236
+ Example:
237
+ >>> pred = element_count("role=button", min_count=1, max_count=5)
238
+ >>> # Will pass if 1-5 buttons found
239
+ """
240
+
241
+ def _pred(ctx: AssertContext) -> AssertOutcome:
242
+ snap = ctx.snapshot
243
+ if snap is None:
244
+ return AssertOutcome(
245
+ passed=False,
246
+ reason="no snapshot available",
247
+ details={"selector": selector, "min_count": min_count, "max_count": max_count},
248
+ )
249
+
250
+ from .query import query
251
+
252
+ matches = query(snap, selector)
253
+ count = len(matches)
254
+
255
+ ok = count >= min_count
256
+ if max_count is not None:
257
+ ok = ok and count <= max_count
258
+
259
+ if ok:
260
+ reason = ""
261
+ else:
262
+ if max_count is not None:
263
+ reason = f"expected {min_count}-{max_count} elements, found {count}"
264
+ else:
265
+ reason = f"expected at least {min_count} elements, found {count}"
266
+
267
+ return AssertOutcome(
268
+ passed=ok,
269
+ reason=reason,
270
+ details={
271
+ "selector": selector,
272
+ "matched": count,
273
+ "min_count": min_count,
274
+ "max_count": max_count,
275
+ },
276
+ )
277
+
278
+ return _pred
279
+
280
+
281
+ def all_of(*predicates: Predicate) -> Predicate:
282
+ """
283
+ Create a predicate that passes only if ALL sub-predicates pass.
284
+
285
+ Args:
286
+ *predicates: Predicate functions to combine with AND logic
287
+
288
+ Returns:
289
+ Combined predicate
290
+
291
+ Example:
292
+ >>> pred = all_of(url_contains("/cart"), exists("text~'Checkout'"))
293
+ >>> # Will pass only if both conditions are true
294
+ """
295
+
296
+ def _pred(ctx: AssertContext) -> AssertOutcome:
297
+ failed_reasons = []
298
+ all_details: list[dict[str, Any]] = []
299
+
300
+ for p in predicates:
301
+ outcome = p(ctx)
302
+ all_details.append(outcome.details)
303
+ if not outcome.passed:
304
+ failed_reasons.append(outcome.reason)
305
+
306
+ ok = len(failed_reasons) == 0
307
+ return AssertOutcome(
308
+ passed=ok,
309
+ reason="; ".join(failed_reasons) if failed_reasons else "",
310
+ details={"sub_predicates": all_details, "failed_count": len(failed_reasons)},
311
+ )
312
+
313
+ return _pred
314
+
315
+
316
+ def any_of(*predicates: Predicate) -> Predicate:
317
+ """
318
+ Create a predicate that passes if ANY sub-predicate passes.
319
+
320
+ Args:
321
+ *predicates: Predicate functions to combine with OR logic
322
+
323
+ Returns:
324
+ Combined predicate
325
+
326
+ Example:
327
+ >>> pred = any_of(exists("text~'Success'"), exists("text~'Complete'"))
328
+ >>> # Will pass if either condition is true
329
+ """
330
+
331
+ def _pred(ctx: AssertContext) -> AssertOutcome:
332
+ all_reasons = []
333
+ all_details: list[dict[str, Any]] = []
334
+
335
+ for p in predicates:
336
+ outcome = p(ctx)
337
+ all_details.append(outcome.details)
338
+ if outcome.passed:
339
+ return AssertOutcome(
340
+ passed=True,
341
+ reason="",
342
+ details={
343
+ "sub_predicates": all_details,
344
+ "matched_at_index": len(all_details) - 1,
345
+ },
346
+ )
347
+ all_reasons.append(outcome.reason)
348
+
349
+ return AssertOutcome(
350
+ passed=False,
351
+ reason=f"none of {len(predicates)} predicates passed: " + "; ".join(all_reasons),
352
+ details={"sub_predicates": all_details},
353
+ )
354
+
355
+ return _pred
356
+
357
+
358
+ def custom(check_fn: Callable[[AssertContext], bool], label: str = "custom") -> Predicate:
359
+ """
360
+ Create a predicate from a custom function.
361
+
362
+ Args:
363
+ check_fn: Function that takes AssertContext and returns bool
364
+ label: Label for debugging/display
365
+
366
+ Returns:
367
+ Predicate wrapping the custom function
368
+
369
+ Example:
370
+ >>> pred = custom(lambda ctx: ctx.snapshot and len(ctx.snapshot.elements) > 10, "has_many_elements")
371
+ """
372
+
373
+ def _pred(ctx: AssertContext) -> AssertOutcome:
374
+ try:
375
+ ok = check_fn(ctx)
376
+ return AssertOutcome(
377
+ passed=ok,
378
+ reason="" if ok else f"custom check '{label}' returned False",
379
+ details={"label": label},
380
+ )
381
+ except Exception as e:
382
+ return AssertOutcome(
383
+ passed=False,
384
+ reason=f"custom check '{label}' raised exception: {e}",
385
+ details={"label": label, "error": str(e)},
386
+ )
387
+
388
+ return _pred
389
+
390
+
391
+ # ============================================================================
392
+ # v1 state-aware predicates (deterministic, schema-driven)
393
+ # ============================================================================
394
+
395
+
396
+ def is_enabled(selector: str) -> Predicate:
397
+ """Passes if any matched element is not disabled (disabled=None treated as enabled)."""
398
+
399
+ def _pred(ctx: AssertContext) -> AssertOutcome:
400
+ snap = ctx.snapshot
401
+ if snap is None:
402
+ return AssertOutcome(
403
+ passed=False, reason="no snapshot available", details={"selector": selector}
404
+ )
405
+
406
+ from .query import query
407
+
408
+ matches = query(snap, selector)
409
+ if not matches:
410
+ return AssertOutcome(
411
+ passed=False,
412
+ reason=f"no elements matched selector: {selector}",
413
+ details={"selector": selector, "matched": 0, "reason_code": "no_match"},
414
+ )
415
+
416
+ ok = any(m.disabled is not True for m in matches)
417
+ return AssertOutcome(
418
+ passed=ok,
419
+ reason="" if ok else f"all matched elements are disabled: {selector}",
420
+ details={
421
+ "selector": selector,
422
+ "matched": len(matches),
423
+ "reason_code": "ok" if ok else "state_mismatch",
424
+ },
425
+ )
426
+
427
+ return _pred
428
+
429
+
430
+ def is_disabled(selector: str) -> Predicate:
431
+ """Passes if any matched element is disabled."""
432
+
433
+ def _pred(ctx: AssertContext) -> AssertOutcome:
434
+ snap = ctx.snapshot
435
+ if snap is None:
436
+ return AssertOutcome(
437
+ passed=False, reason="no snapshot available", details={"selector": selector}
438
+ )
439
+
440
+ from .query import query
441
+
442
+ matches = query(snap, selector)
443
+ ok = any(m.disabled is True for m in matches)
444
+ return AssertOutcome(
445
+ passed=ok,
446
+ reason="" if ok else f"no matched elements are disabled: {selector}",
447
+ details={
448
+ "selector": selector,
449
+ "matched": len(matches),
450
+ "reason_code": "ok" if ok else "state_mismatch",
451
+ },
452
+ )
453
+
454
+ return _pred
455
+
456
+
457
+ def is_checked(selector: str) -> Predicate:
458
+ """Passes if any matched element is checked."""
459
+
460
+ def _pred(ctx: AssertContext) -> AssertOutcome:
461
+ snap = ctx.snapshot
462
+ if snap is None:
463
+ return AssertOutcome(
464
+ passed=False, reason="no snapshot available", details={"selector": selector}
465
+ )
466
+
467
+ from .query import query
468
+
469
+ matches = query(snap, selector)
470
+ ok = any(m.checked is True for m in matches)
471
+ return AssertOutcome(
472
+ passed=ok,
473
+ reason="" if ok else f"no matched elements are checked: {selector}",
474
+ details={
475
+ "selector": selector,
476
+ "matched": len(matches),
477
+ "reason_code": "ok" if ok else "state_mismatch",
478
+ },
479
+ )
480
+
481
+ return _pred
482
+
483
+
484
+ def is_unchecked(selector: str) -> Predicate:
485
+ """Passes if any matched element is not checked (checked=None treated as unchecked)."""
486
+
487
+ def _pred(ctx: AssertContext) -> AssertOutcome:
488
+ snap = ctx.snapshot
489
+ if snap is None:
490
+ return AssertOutcome(
491
+ passed=False, reason="no snapshot available", details={"selector": selector}
492
+ )
493
+
494
+ from .query import query
495
+
496
+ matches = query(snap, selector)
497
+ ok = any(m.checked is not True for m in matches)
498
+ return AssertOutcome(
499
+ passed=ok,
500
+ reason="" if ok else f"all matched elements are checked: {selector}",
501
+ details={
502
+ "selector": selector,
503
+ "matched": len(matches),
504
+ "reason_code": "ok" if ok else "state_mismatch",
505
+ },
506
+ )
507
+
508
+ return _pred
509
+
510
+
511
+ def value_equals(selector: str, expected: str) -> Predicate:
512
+ """Passes if any matched element has value exactly equal to expected."""
513
+
514
+ def _pred(ctx: AssertContext) -> AssertOutcome:
515
+ snap = ctx.snapshot
516
+ if snap is None:
517
+ return AssertOutcome(
518
+ passed=False, reason="no snapshot available", details={"selector": selector}
519
+ )
520
+
521
+ from .query import query
522
+
523
+ matches = query(snap, selector)
524
+ ok = any((m.value or "") == expected for m in matches)
525
+ return AssertOutcome(
526
+ passed=ok,
527
+ reason="" if ok else f"no matched elements had value == '{expected}'",
528
+ details={
529
+ "selector": selector,
530
+ "expected": expected,
531
+ "matched": len(matches),
532
+ "reason_code": "ok" if ok else "state_mismatch",
533
+ },
534
+ )
535
+
536
+ return _pred
537
+
538
+
539
+ def value_contains(selector: str, substring: str) -> Predicate:
540
+ """Passes if any matched element value contains substring (case-insensitive)."""
541
+
542
+ def _pred(ctx: AssertContext) -> AssertOutcome:
543
+ snap = ctx.snapshot
544
+ if snap is None:
545
+ return AssertOutcome(
546
+ passed=False, reason="no snapshot available", details={"selector": selector}
547
+ )
548
+
549
+ from .query import query
550
+
551
+ matches = query(snap, selector)
552
+ ok = any(substring.lower() in (m.value or "").lower() for m in matches)
553
+ return AssertOutcome(
554
+ passed=ok,
555
+ reason="" if ok else f"no matched elements had value containing '{substring}'",
556
+ details={
557
+ "selector": selector,
558
+ "substring": substring,
559
+ "matched": len(matches),
560
+ "reason_code": "ok" if ok else "state_mismatch",
561
+ },
562
+ )
563
+
564
+ return _pred
565
+
566
+
567
+ def is_expanded(selector: str) -> Predicate:
568
+ """Passes if any matched element is expanded."""
569
+
570
+ def _pred(ctx: AssertContext) -> AssertOutcome:
571
+ snap = ctx.snapshot
572
+ if snap is None:
573
+ return AssertOutcome(
574
+ passed=False, reason="no snapshot available", details={"selector": selector}
575
+ )
576
+
577
+ from .query import query
578
+
579
+ matches = query(snap, selector)
580
+ ok = any(m.expanded is True for m in matches)
581
+ return AssertOutcome(
582
+ passed=ok,
583
+ reason="" if ok else f"no matched elements are expanded: {selector}",
584
+ details={
585
+ "selector": selector,
586
+ "matched": len(matches),
587
+ "reason_code": "ok" if ok else "state_mismatch",
588
+ },
589
+ )
590
+
591
+ return _pred
592
+
593
+
594
+ def is_collapsed(selector: str) -> Predicate:
595
+ """Passes if any matched element is not expanded (expanded=None treated as collapsed)."""
596
+
597
+ def _pred(ctx: AssertContext) -> AssertOutcome:
598
+ snap = ctx.snapshot
599
+ if snap is None:
600
+ return AssertOutcome(
601
+ passed=False, reason="no snapshot available", details={"selector": selector}
602
+ )
603
+
604
+ from .query import query
605
+
606
+ matches = query(snap, selector)
607
+ ok = any(m.expanded is not True for m in matches)
608
+ return AssertOutcome(
609
+ passed=ok,
610
+ reason="" if ok else f"all matched elements are expanded: {selector}",
611
+ details={
612
+ "selector": selector,
613
+ "matched": len(matches),
614
+ "reason_code": "ok" if ok else "state_mismatch",
615
+ },
616
+ )
617
+
618
+ return _pred