diff --git a/functional_tests/test_app_settings_auxiliary_writers.py b/functional_tests/test_app_settings_auxiliary_writers.py index 97ef258a9..063b31169 100644 --- a/functional_tests/test_app_settings_auxiliary_writers.py +++ b/functional_tests/test_app_settings_auxiliary_writers.py @@ -1,12 +1,14 @@ # test_app_settings_auxiliary_writers.py """ Functional tests for auxiliary app-settings writers. -Version: 0.261.025 +Version: 0.261.213 Implemented in: 0.261.025 Execute isolated production functions through AST extraction, without importing application configuration or contacting Redis, Cosmos DB, or other cloud services. -Verify field-only updates, optimistic concurrency, and rejected-write responses. +The dependency-free action manifest contract (functions_action_manifest) is +imported as shipped. Verify field-only updates, optimistic concurrency, and +rejected-write responses. """ import ast @@ -22,6 +24,10 @@ APP_DIR = Path(__file__).resolve().parents[1] / "application" / "single_app" +sys.path.insert(0, str(APP_DIR)) + +from functions_action_manifest import resolve_action_type # noqa: E402 + WRITER_FILES = ( "background_tasks.py", "functions_control_center.py", @@ -432,14 +438,23 @@ def save_global_action(manifest): raise RuntimeError("container unavailable") return manifest if container_result == "saved" else None + logged = [] route = load_function( "plugin_validation_endpoint.py", "repair_plugin", store, discover_plugins=lambda: {"Example": object}, PluginHealthChecker=health_checker, PluginErrorRecovery=recovery, save_global_action=save_global_action, + resolve_action_type=resolve_action_type, + _find_plugin_class=load_function( + "plugin_validation_endpoint.py", "_find_plugin_class", store, + resolve_action_type=resolve_action_type, + ), + log_event=lambda message, *args, **kwargs: logged.append(message), ) result, status = response_parts(route("repair-me")) + # The route reports any failure, including a name this harness lacks, through log_event. + assert store.calls, f"repair_plugin must reach its settings write, but it logged {logged!r}" assert_delta(store, {"semantic_kernel_plugins"}, etag='"original"') assert status == (200 if available and not conflict else 500) assert result["success"] is (available and not conflict) diff --git a/functional_tests/test_block_revision_assist.py b/functional_tests/test_block_revision_assist.py index dce3abaf6..f2670a0f1 100644 --- a/functional_tests/test_block_revision_assist.py +++ b/functional_tests/test_block_revision_assist.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Functional test for the scoped diagram edit request. -Version: 0.261.043 +Version: 0.261.213 Implemented in: 0.261.043 This test ensures that asking the AI to change a diagram sends only that diagram's own @@ -178,7 +178,11 @@ def test_the_prompt_is_honest_about_positioning(): """The model must not be encouraged to fake node placement Mermaid cannot express.""" print("Testing positioning guidance in the prompt...") try: - prompt = assist.ASSIST_SYSTEM_PROMPT + # Read the system prompt a diagram edit actually sends rather than a module constant: + # adding chart editing renamed the constant when it gave each block kind its own prompt. + messages = build_assist_messages(CURRENT_SOURCE, 'put B to the left of A') + assert messages[0]['role'] == 'system' + prompt = messages[0]['content'] assert 'no syntax for placing a node at a coordinate' in prompt assert 'flow direction' in prompt # And the prompt injection instruction is present, since the source is untrusted. diff --git a/functional_tests/test_group_collaboration_source_storage_fix.py b/functional_tests/test_group_collaboration_source_storage_fix.py index 4e84e4305..8b2872664 100644 --- a/functional_tests/test_group_collaboration_source_storage_fix.py +++ b/functional_tests/test_group_collaboration_source_storage_fix.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional regression for group conversation source storage. -Version: 0.261.106 +Version: 0.261.214 Implemented in: 0.261.024 Ported to the React branch in: 0.261.106 Related issue: microsoft/simplechat#1472 @@ -14,12 +14,17 @@ """ import ast +import builtins from copy import deepcopy from datetime import datetime, timezone +import dis +import functools +import inspect import logging from pathlib import Path import runpy -from types import SimpleNamespace +import sys +from types import CodeType, FunctionType, SimpleNamespace from typing import Any, Dict, Iterable, List, Optional import unittest @@ -33,8 +38,21 @@ APP_ROOT = Path(__file__).resolve().parents[1] / 'application' / 'single_app' +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +# Test-only path setup precedes these real modules; none of them load config or Azure clients. +import functions_m365_history # noqa: E402 +from functions_m365_action_cards import strip_pending_action_references # noqa: E402 +from functions_m365_approvals import M365ApprovalRequired, M365PolicyError, material_fingerprint # noqa: E402 +from functions_workflow_result_masking import ( # noqa: E402 + message_uses_workflow_result, + withhold_workflow_result_message, +) + COLLABORATION_FILE = APP_ROOT / 'functions_collaboration.py' ROUTE_FILE = APP_ROOT / 'route_backend_collaboration.py' +RUNTIME_FILE = APP_ROOT / 'functions_m365_runtime.py' SOURCE_ID = 'source-conversation' GROUP_ID = 'group-1' OWNER = { @@ -83,6 +101,7 @@ 'get_personal_collaboration_conversation_by_source_conversation', '_is_eligible_legacy_personal_conversation', '_copy_legacy_personal_messages_to_collaboration', + '_carry_summary_into_collaboration', 'ensure_personal_collaboration_for_legacy_conversation', '_is_eligible_legacy_group_conversation', '_copy_legacy_group_messages_to_collaboration', @@ -98,11 +117,15 @@ '_collaboration_retention_identity', '_read_collaboration_conversation_for_retention', '_cleanup_collaboration_thoughts', + '_read_linked_collaboration_source', + '_cancel_collaboration_pending_deliveries', '_cleanup_linked_collaboration_source', '_delete_collaboration_conversation_records', 'delete_collaboration_conversation_for_retention', 'delete_personal_collaboration_conversation', } +# Store callbacks nested in production's history runtime setup. +HISTORY_RUNTIME_CALLBACKS = ('read_conversation', 'read_messages', 'audience', 'has_active_request') class StorageFailure(RuntimeError): @@ -216,6 +239,59 @@ def history_fixture(): return list(reversed(messages)) +def fixture_has_no_m365_sources(*args, **kwargs): + raise AssertionError('The fixture history has no Microsoft 365 sources to publish.') + + +# Approval records and evidence publication are unreachable without Microsoft 365 sources. +NO_M365_PUBLICATION = SimpleNamespace( + upsert_item=fixture_has_no_m365_sources, + authorize_sources=fixture_has_no_m365_sources, +) + + +def load_history_runtime_callbacks(namespace): + """Run production's nested history store callbacks against the harness stores.""" + tree = ast.parse(RUNTIME_FILE.read_text(encoding='utf-8'), filename=str(RUNTIME_FILE)) + setup = next(( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == 'configure_m365_history_runtime' + ), None) + callbacks = [ + node for node in (setup.body if setup else []) + if isinstance(node, ast.FunctionDef) and node.name in HISTORY_RUNTIME_CALLBACKS + ] + found = sorted(node.name for node in callbacks) + if found != sorted(HISTORY_RUNTIME_CALLBACKS): + raise AssertionError( + f'configure_m365_history_runtime should define {sorted(HISTORY_RUNTIME_CALLBACKS)}; found {found}.' + ) + loaded_namespace = dict(namespace) + loaded_namespace.setdefault('__builtins__', __builtins__) + exec(compile(ast.Module(body=callbacks, type_ignores=[]), str(RUNTIME_FILE), 'exec'), loaded_namespace) + return loaded_namespace + + +def global_reads(code): + """Return the global names a compiled function or its nested code reads.""" + names = { + instruction.argval for instruction in dis.get_instructions(code) + if instruction.opname == 'LOAD_GLOBAL' + } + for constant in code.co_consts: + if isinstance(constant, CodeType): + names |= global_reads(constant) + return names + + +def production_functions(namespace): + """Yield the functions compiled from production source into namespace.""" + for name, value in namespace.items(): + function = inspect.unwrap(value) if isinstance(value, FunctionType) else None + if function is not None and function.__globals__ is namespace: + yield name, function + + class ConversionHarness: """Load actual helpers with in-memory storage and controlled external effects.""" @@ -241,6 +317,8 @@ def __init__(self, storage='regular', history=True): if name == 'collaboration_user_state': partition_field = 'user_id' self.containers[name] = TrackingContainer(partition_field) + # Production partitions Microsoft 365 execution runs by user. + self.containers['m365_execution_runs'] = TrackingContainer('user_id') prefix = 'group_' if storage == 'group' else '' self.source = self.containers[f'{prefix}conversations'] self.messages = self.containers[f'{prefix}messages'] @@ -262,14 +340,15 @@ def __init__(self, storage='regular', history=True): self.logs = [] self.thought_cleanup = [] self.blob_cleanup = [] - group_helpers = load_source_members( + self.cancelled_deliveries = [] + self.group_helpers = load_source_members( str(APP_ROOT / 'functions_group.py'), {'get_user_role_in_group', 'assert_group_role', 'check_group_status_allows_operation'}, namespace={'Iterable': Iterable, 'find_group_by_id': self.find_group}, ) namespace = { **MODEL_HELPERS, - **group_helpers, + **self.group_helpers, 'deepcopy': deepcopy, 'datetime': datetime, 'timezone': timezone, @@ -281,7 +360,13 @@ def __init__(self, storage='regular', history=True): 'log_conversation_archival': lambda **kwargs: None, 'log_conversation_deletion': lambda **kwargs: None, 'sync_chat_upload_workspace_document_sharing_for_collaboration': lambda item: None, - '_delete_blob_backed_collaboration_files': lambda messages: self.blob_cleanup.extend( + 'strip_pending_action_references': strip_pending_action_references, + 'message_uses_workflow_result': message_uses_workflow_result, + 'withhold_workflow_result_message': withhold_workflow_result_message, + 'cancel_m365_conversation_deliveries': lambda conversation_id: ( + self.cancelled_deliveries.append(conversation_id) + ), + '_delete_blob_backed_collaboration_files': lambda messages, conversation=None: self.blob_cleanup.extend( message['id'] for message in messages ), 'archive_thoughts_for_conversation': lambda conversation_id, user_id, **kwargs: ( @@ -301,10 +386,43 @@ def __init__(self, storage='regular', history=True): assignment_names={'CITATION_TRACKING_CONVERSATION_FIELDS', 'PERSONAL_COLLABORATION_MANAGER_ROLES'}, namespace=namespace, ) + self.history_namespace = load_history_runtime_callbacks({ + **{f'cosmos_{name}_container': container for name, container in self.containers.items()}, + 'assert_group_role': self.group_helpers['assert_group_role'], + 'find_group_by_id': self.find_group, + 'material_fingerprint': material_fingerprint, + }) + self.history_service = functions_m365_history.M365HistoryService( + tenant_id='test-tenant', + jobs=NO_M365_PUBLICATION, + approvals=NO_M365_PUBLICATION, + read_conversation=self.history_namespace['read_conversation'], + read_messages=self.history_namespace['read_messages'], + memory_resolver=fixture_has_no_m365_sources, + audience_resolver=self.history_namespace['audience'], + has_active_request=self.history_namespace['has_active_request'], + ) + for name in ( + 'ensure_group_collaboration_for_legacy_conversation', + 'ensure_personal_collaboration_for_legacy_conversation', + ): + self.namespace[name] = self.with_history_service(self.namespace[name]) def find_group(self, group_id): return deepcopy(self.group) if self.group and self.group['id'] == group_id else None + def with_history_service(self, function): + """Configure the history service only while one conversion runs.""" + @functools.wraps(function) + def convert_with_history_service(*args, **kwargs): + previous_service = functions_m365_history._service + functions_m365_history.configure_m365_history(self.history_service) + try: + return function(*args, **kwargs) + finally: + functions_m365_history.configure_m365_history(previous_service) + return convert_with_history_service + def invalidate(self, item, reason): self.invalidations.append({ 'item': deepcopy(item), @@ -341,6 +459,8 @@ def build_route_app(self): 'request': request, 'get_settings': lambda: {'enable_collaborative_conversations': self.feature_enabled}, 'get_current_user_info': lambda: self.user, + 'M365ApprovalRequired': M365ApprovalRequired, + 'M365PolicyError': M365PolicyError, 'swagger_route': lambda **kwargs: (lambda function: function), 'get_auth_security': lambda: {}, 'login_required': lambda function: function, @@ -764,6 +884,11 @@ def test_manual_and_retention_cleanup_follow_converted_source_links(self): self.assertEqual(set(harness.blob_cleanup), copied_ids | source_message_ids) thought_operation = 'archive' if mode == 'archive' else 'delete' self.assertIn((thought_operation, SOURCE_ID, OWNER['user_id']), harness.thought_cleanup) + # Destination first, then each reciprocally linked source; the decoy is never cancelled. + expected_cancellations = [conversation['id'], backing['id']] + if storage == 'group': + expected_cancellations.append(SOURCE_ID) + self.assertEqual(harness.cancelled_deliveries, expected_cancellations) def test_linked_source_cleanup_keeps_owner_and_backlink_guards(self): for storage in ('regular', 'group'): @@ -785,6 +910,7 @@ def test_linked_source_cleanup_keeps_owner_and_backlink_guards(self): self.assertIsNone(result) self.assertEqual(harness.snapshot(), before) self.assertEqual(harness.messages.calls, message_calls_before) + self.assertFalse(harness.cancelled_deliveries) def test_existing_personal_conversion_is_unchanged(self): harness = ConversionHarness() @@ -897,6 +1023,42 @@ def test_v1_storage_failures_surface_as_safe_500_responses(self): self.assertFalse(harness.events) self.assertTrue(harness.logs) + def test_harness_binds_every_global_the_loaded_production_code_reads(self): + harness = ConversionHarness('group') + harness.build_route_app() + namespaces = { + 'collaboration': harness.namespace, + 'group': harness.group_helpers, + 'history runtime': harness.history_namespace, + 'route': harness.route_namespace, + 'artifact': ARTIFACT_HELPERS, + } + checked = {label: dict(production_functions(namespace)) for label, namespace in namespaces.items()} + unbound = {} + for label, functions in checked.items(): + for name, function in functions.items(): + missing = sorted( + global_reads(function.__code__) - set(namespaces[label]) - set(dir(builtins)) + ) + if missing: + unbound[f'{label}.{name}'] = missing + self.assertEqual( + unbound, {}, + 'Seed these production globals in ConversionHarness, or the code paths that read them ' + 'fail with NameError.', + ) + self.assertIn('ensure_group_collaboration_for_legacy_conversation', checked['collaboration']) + self.assertIn('convert_group_conversation_to_collaboration_api', checked['route']) + self.assertLessEqual(set(HISTORY_RUNTIME_CALLBACKS), set(checked['history runtime'])) + + def test_harness_scopes_the_history_service_to_each_conversion(self): + previous_service = functions_m365_history._service + harness = ConversionHarness() + _, _, created, _ = harness.convert() + restored_service = functions_m365_history._service + self.assertTrue(created) + self.assertIs(restored_service, previous_service, 'The harness leaked its history service.') + if __name__ == '__main__': unittest.main() diff --git a/functional_tests/test_orchestration_plan_revision_routes.py b/functional_tests/test_orchestration_plan_revision_routes.py index ca4774915..30177316f 100644 --- a/functional_tests/test_orchestration_plan_revision_routes.py +++ b/functional_tests/test_orchestration_plan_revision_routes.py @@ -1,7 +1,7 @@ # test_orchestration_plan_revision_routes.py """ Functional tests for conversational, pre-execution plan revisions. -Version: 0.261.200 +Version: 0.261.213 Implemented in: 0.261.102 Authorized model routing through revisions and clarification: 0.261.103 Unroutable Auto revisions report model_unavailable: 0.261.134 @@ -12,6 +12,7 @@ Only model, search, source-access, and Cosmos service boundaries are replaced. """ +import importlib import json import unittest import uuid @@ -108,12 +109,13 @@ def manifest(ids, user_id, **kwargs): return [ { 'document_id': document_id, 'file_name': f'{document_id}.pdf', - 'scope': 'personal', + 'scope': 'personal', 'source_kind': 'narrative', 'authorization_status': 'authorized' if document_id in self.allowed_documents else 'denied', } for document_id in ids ] + self.source_manifest = manifest self.model.chat.completions.create = completion # The config-stub loader restores sys.modules; patch the route-bound module. patcher = patch.dict(self.route.build_plan_edit_outcome.__globals__, { @@ -375,8 +377,26 @@ def test_review_narrowing_reaches_the_planner(self): self.assertFalse(next(step for step in supplied['steps'] if step['step_id'] == 'search_1')['enabled']) self.assertEqual(updated['edits'], {'disabled_step_ids': [], 'removed_document_ids': {}}) + def use_picked_documents(self): + """Plan with picked documents under the same authority the plan editor uses. + + Planning confirms every picked document against the strict current-authority + manifest before the planner sees it, and the base harness stubs candidate discovery + out. Restore the production explicit-selection branch, which makes no service call, + and answer the strict check from ``allowed_documents``. + """ + context = self.route.enrich_planner_candidates.__globals__ + source_access = importlib.import_module('functions_orchestration_source_access') + for patcher in ( + patch.object(self.route, 'resolve_candidate_documents', context['resolve_candidate_documents']), + patch.object(source_access, 'resolve_orchestration_source_manifest', self.source_manifest), + ): + patcher.start() + self.addCleanup(patcher.stop) + def test_document_removal_is_not_lost_when_editor_opens(self): self.allowed_documents = {'docA', 'docB'} + self.use_picked_documents() self.model.plan_override = revised_plan(searches=1) self.model.plan_override['steps'][0]['arguments']['document_ids'] = ['docA', 'docB'] plan = self.planned(selected_document_ids=['docA', 'docB']) diff --git a/functional_tests/test_orchestration_turn_recovery_identifiers.py b/functional_tests/test_orchestration_turn_recovery_identifiers.py index a55e98415..76e1da28f 100644 --- a/functional_tests/test_orchestration_turn_recovery_identifiers.py +++ b/functional_tests/test_orchestration_turn_recovery_identifiers.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for the identifiers that let an orchestration turn be found again. -Version: 0.261.131 +Version: 0.261.213 Implemented in: 0.261.099 Chat content-check metadata binding included in: 0.261.131 @@ -151,6 +151,7 @@ def create_run(plan, user_id, **kwargs): memory = import_app_module("functions_orchestration_memory") checks = import_app_module("functions_chat_content_checks") + context = import_app_module("functions_orchestration_context") authorize = Mock(return_value={"id": "conversation-1", "user_id": "user-1"}) latest_run = Mock(return_value=None) namespace = { @@ -159,6 +160,7 @@ def create_run(plan, user_id, **kwargs): '_authorize_context_conversation': authorize, 'get_latest_turn_run': latest_run, 'CHECK_METADATA': checks.CHECK_METADATA, + 'image_reference_provenance_from_seeds': context.image_reference_provenance_from_seeds, 'g': g, 'has_request_context': has_request_context, } for name in ('_validate_turn_memory_context', '_persist_planned_turn'): diff --git a/functional_tests/test_v2_admin_workflow_parity.py b/functional_tests/test_v2_admin_workflow_parity.py index 27733da93..c8aeef026 100644 --- a/functional_tests/test_v2_admin_workflow_parity.py +++ b/functional_tests/test_v2_admin_workflow_parity.py @@ -2,7 +2,7 @@ # test_v2_admin_workflow_parity.py """ Functional test pinning V1/V2 parity for the Admin Settings Workflow group. -Version: 0.261.208 +Version: 0.261.213 Implemented in: 0.261.059 The Workflow group rendered completely empty in the V2 React admin surface. The @@ -249,10 +249,32 @@ def test_sub_settings_are_gated_by_their_capability(): Worth remembering as a shape: fixing a duplicate declaration can expose a bug in code that only ever saw the other copy. + + The chain-aware reading had not landed with that note, so the crash stayed live. + Every link is now read through ``iter_field_dependencies``, as the renderer reads + it. A list means every link must hold, so link order carries no meaning: the + expected gate has to be one of the links, and any other link has to be one the + gate itself requires, because a link the gate does not need hides the field + while its capability is live. """ print("\nTesting the workflow gating chain...") declared = {field["key"]: field for field in workflow_fields() if field.get("key")} + fields_by_key = { + field["key"]: field + for _section_id, field in fields_module.iter_fields() + if field.get("key") + } + + def inherited_conditions(gate_key, seen=None): + """Every (key, equals) a gate is itself gated on, followed up its own gates.""" + seen = set() if seen is None else seen + for condition in fields_module.iter_field_dependencies(fields_by_key.get(gate_key, {})): + entry = (condition.get("key"), condition.get("equals", True)) + if entry not in seen: + seen.add(entry) + inherited_conditions(entry[0], seen) + return seen problems = [] for key, expected_gate in EXPECTED_DEPENDENCIES.items(): @@ -260,13 +282,28 @@ def test_sub_settings_are_gated_by_their_capability(): if field is None: problems.append(f"{key}: not declared") continue - depends_on = field.get("depends_on") or {} - if depends_on.get("key") != expected_gate: + conditions = list(fields_module.iter_field_dependencies(field)) + gate_links = [c for c in conditions if c.get("key") == expected_gate] + if not gate_links: + problems.append( + f"{key}: gated on {[c.get('key') for c in conditions]!r}, " + f"expected {expected_gate!r}" + ) + for condition in gate_links: + if condition.get("equals") is not True: + problems.append(f"{key}: gate expects {condition.get('equals')!r}, not True") + allowed = {(expected_gate, True)} | inherited_conditions(expected_gate) + unrelated = [ + condition.get("key") + for condition in conditions + if condition.get("key") != expected_gate + and (condition.get("key"), condition.get("equals", True)) not in allowed + ] + if unrelated: problems.append( - f"{key}: gated on {depends_on.get('key')!r}, expected {expected_gate!r}" + f"{key}: also gated on {unrelated!r}, which {expected_gate!r} does not " + "itself require, so the field hides while its capability is live" ) - if depends_on.get("equals") is not True: - problems.append(f"{key}: gate expects {depends_on.get('equals')!r}, not True") for key in UNGATED_KEYS: field = declared.get(key) @@ -274,8 +311,9 @@ def test_sub_settings_are_gated_by_their_capability(): problems.append(f"{key}: not declared") continue if field.get("depends_on"): + gates = [c.get("key") for c in fields_module.iter_field_dependencies(field)] problems.append( - f"{key}: gated on {field['depends_on'].get('key')!r}. It bounds both " + f"{key}: gated on {gates!r}. It bounds both " "personal and group runs, so gating it on one capability hides a live " "limit from administrators who use the other." ) diff --git a/functional_tests/test_v2_prompt_attachment_persistence.py b/functional_tests/test_v2_prompt_attachment_persistence.py index 40e8c28a0..11b4ab793 100644 --- a/functional_tests/test_v2_prompt_attachment_persistence.py +++ b/functional_tests/test_v2_prompt_attachment_persistence.py @@ -2,10 +2,11 @@ #!/usr/bin/env python3 """ Functional regression for prompt attachments at actual message persistence boundaries. -Version: 0.261.122 +Version: 0.261.213 Implemented in: 0.261.096 Turn-reuse integration coverage expanded in: 0.261.097 Frozen turn-context persistence integration: 0.261.099 +Harness namespace re-synced with the routes in: 0.261.213 Execute the shipping metadata helper, chat persistence statements, orchestration writer, and shared post/stream routes against in-memory Cosmos containers. Importing the full chat @@ -23,7 +24,7 @@ import uuid from copy import deepcopy from datetime import datetime, timezone -from functools import lru_cache +from functools import lru_cache, partial from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, call @@ -36,6 +37,13 @@ # These pure application modules need the application import path, not app/config startup. import collaboration_models # noqa: E402 +from functions_chat_content_checks import ( # noqa: E402 + CHECK_CONTEXT_KEY, + CHECK_METADATA, + attach_chat_check, + check_chat_content, + strip_private_chat_checks, +) from functions_chat_stream_events import build_user_message_persisted_stream_event # noqa: E402 from functions_prompt_metadata import build_prompt_selection_metadata # noqa: E402 from test_orchestration_conversation_context import load_modules # noqa: E402 @@ -45,6 +53,22 @@ SEND_PATHS = ("chat", "chat-stream", "document-action", "orchestration", "shared-post", "shared-stream") USER = {"user_id": "author-1", "display_name": "Author", "email": "author@example.test"} +BOUNDARY_FILENAME = "" + +# chat_api's initial value for this local, which its new-message branch reads. The closed +# namespace seeds it with the other route locals; a test keeps it equal to the route literal. +ROUTE_IMAGE_REFERENCES_INITIAL = { + "sources": [], + "provenance": [], + "mask": None, + "mask_metadata": None, + "input_fidelity": "", +} + +# Content checks are orthogonal to prompt metadata. With every chat scanner off, the shipping +# check_chat_content returns its "not_required" decision without reaching settings or Azure. +CHAT_CONTENT_CHECKS_OFF = {"enable_content_safety": False, "enable_content_screening": False} +check_chat_content_with_checks_off = partial(check_chat_content, settings=CHAT_CONTENT_CHECKS_OFF) class MemoryContainer: @@ -105,19 +129,36 @@ def _is_message_write(statement): ) +def _raised_by_boundary(error): + frame = error.__traceback__ + while frame.tb_next is not None: + frame = frame.tb_next + return frame.tb_frame.f_code.co_filename == BOUNDARY_FILENAME + + def _execute_boundary(statements, namespace, return_name, events): wrapper = ast.parse("def exercise_boundary():\n pass\n").body[0] wrapper.body = deepcopy(statements) + [ast.Return(value=ast.Name(id=return_name, ctx=ast.Load()))] module = ast.fix_missing_locations(ast.Module(body=[wrapper], type_ignores=[])) - exec(compile(module, "", "exec"), namespace) - result = namespace["exercise_boundary"]() - if not inspect.isgenerator(result): - return result - while True: - try: - events.append(next(result)) - except StopIteration as finished: - return finished.value + exec(compile(module, BOUNDARY_FILENAME, "exec"), namespace) + try: + result = namespace["exercise_boundary"]() + if not inspect.isgenerator(result): + return result + while True: + try: + events.append(next(result)) + except StopIteration as finished: + return finished.value + except NameError as missing: + if isinstance(missing, UnboundLocalError) or not _raised_by_boundary(missing): + raise + # A route started reading a name the closed namespace lacks; say so instead of a bare NameError. + raise AssertionError( + f"The shipping persistence boundary now reads {missing.name!r}, which this harness's " + "closed namespace does not define. Seed it next to the other route locals in the " + "matching *_boundary namespace, using the value the route has at that point." + ) from missing def prompt_cases(): @@ -165,9 +206,8 @@ def prompt_cases(): ] -def _chat_boundary(path, info, content): - storage = MemoryContainer() - events = [] +def _chat_namespace(info, content, storage, events): + """The closed namespace for chat_api, chat_stream_api and document-action branches.""" author = { "userId": USER["user_id"], "displayName": USER["display_name"], "userPrincipalName": USER["email"], "email": USER["email"], @@ -179,6 +219,7 @@ def _chat_boundary(path, info, content): "cosmos_messages_container": storage, "build_prompt_selection_metadata": build_prompt_selection_metadata, "build_user_message_persisted_stream_event": build_user_message_persisted_stream_event, + "attach_chat_check": attach_chat_check, "get_current_user_info": lambda: author, "publish_background_event": events.append, "debug_print": lambda *args, **kwargs: None, @@ -193,12 +234,23 @@ def _chat_boundary(path, info, content): "frontend_gpt_model": "test-deployment", "gpt_endpoint_id": None, "gpt_model_id": None, "gpt_provider": "azure_openai", "gpt_model_icon": None, "gpt_response_length": 1000, "reasoning_effort": None, "search_results": [], + "prepared_chat_image_references": deepcopy(ROUTE_IMAGE_REFERENCES_INITIAL), + "input_check": check_chat_content_with_checks_off( + content, "chat_input", user_id=USER["user_id"], + ), } for flag in ( "image_gen_enabled", "hybrid_search_enabled", "web_search_enabled", "url_access_enabled", "source_review_enabled", "deep_research_enabled", ): namespace[flag] = False + return namespace + + +def _chat_boundary(path, info, content): + storage = MemoryContainer() + events = [] + namespace = _chat_namespace(info, content, storage, events) if path == "document-action": function = _function("route_backend_chats.py", "execute_document_action_chat_request") @@ -250,6 +302,7 @@ def _orchestration_boundary(info, content): storage = MemoryContainer() context = load_modules().context memory = import_app_module("functions_orchestration_memory") + registry = import_app_module("functions_orchestration_registry") authorize = Mock(return_value={"id": "conversation-1", "user_id": USER["user_id"]}) latest_run = Mock(return_value=None) persisted_contexts = [] @@ -275,12 +328,15 @@ def capture_run( "has_request_context": has_request_context, "g": g, "get_latest_turn_run": latest_run, "normalize_history_message": context.normalize_history_message, + "image_reference_provenance_from_seeds": context.image_reference_provenance_from_seeds, "ConversationContextError": context.ConversationContextError, "CosmosResourceNotFoundError": KeyError, "build_prompt_selection_metadata": build_prompt_selection_metadata, "cosmos_messages_container": storage, "datetime": datetime, "timezone": timezone, "uuid": uuid, "logging": logging, "log_event": Mock(), "create_orchestration_run": capture_run, + "CHECK_METADATA": CHECK_METADATA, + "DEPENDENCY_PLAN_CONTRACT_VERSION": registry.DEPENDENCY_PLAN_CONTRACT_VERSION, } _load_functions( "route_backend_orchestration.py", namespace, @@ -374,6 +430,9 @@ def authorize(user_id, document): "CosmosResourceNotFoundError": type("MissingRecord", (Exception,), {}), "build_prompt_selection_metadata": build_prompt_selection_metadata, "build_user_message_persisted_stream_event": build_user_message_persisted_stream_event, + "check_chat_content": check_chat_content_with_checks_off, + "CHECK_CONTEXT_KEY": CHECK_CONTEXT_KEY, + "strip_private_chat_checks": strip_private_chat_checks, "_require_collaboration_feature_enabled": lambda: None, "_get_current_collaboration_user": lambda: USER, "get_collaboration_conversation": lambda identifier: conversations.read_item(identifier, identifier), @@ -537,6 +596,49 @@ def test_only_active_string_variables_are_recorded(): assert metadata["prompt_edited"] is False +def _image_reference_initializers(): + return [ + node for node in ast.walk(_function("route_backend_chats.py", "chat_api")) + if _assigns(node, "prepared_chat_image_references") and isinstance(node.value, ast.Dict) + ] + + +def test_chat_harness_seeds_the_route_initial_image_references(): + initializers = _image_reference_initializers() + namespace = _chat_namespace(None, "Only the user's text.", MemoryContainer(), []) + assert len(initializers) == 1, ( + "Expected chat_api to initialize prepared_chat_image_references with one dict literal, " + f"found {len(initializers)}. Re-check what its new-message branch reads." + ) + initializer = initializers[0].value + try: + route_value = ast.literal_eval(initializer) + except ValueError: + route_value = None + assert isinstance(route_value, dict), ( + "chat_api now initializes prepared_chat_image_references with a non-literal value, " + f"{ast.unparse(initializer)}. Seed the harness with the value the route has at that point." + ) + added = sorted(set(route_value) - set(ROUTE_IMAGE_REFERENCES_INITIAL)) + removed = sorted(set(ROUTE_IMAGE_REFERENCES_INITIAL) - set(route_value)) + assert not added and not removed, ( + "chat_api's prepared_chat_image_references keys drifted from ROUTE_IMAGE_REFERENCES_INITIAL: " + f"the route added {added} and dropped {removed}. Update the seed to the route's literal." + ) + assert route_value == ROUTE_IMAGE_REFERENCES_INITIAL, ( + f"chat_api now initializes prepared_chat_image_references as {route_value!r}; " + "update ROUTE_IMAGE_REFERENCES_INITIAL to the route's literal." + ) + assert "prepared_chat_image_references" in namespace, ( + "The chat harness namespace must seed prepared_chat_image_references: chat_api's " + "new-message branch reads it before the user message is written." + ) + assert namespace["prepared_chat_image_references"] == route_value, ( + "The chat harness must seed chat_api's initial image references, not " + f"{namespace['prepared_chat_image_references']!r}." + ) + + def test_version_includes_the_persistence_fix(): assert_app_version_at_least("0.261.096") diff --git a/functional_tests/test_v2_prompt_composer_card_logic.ts b/functional_tests/test_v2_prompt_composer_card_logic.ts index 955d80335..df0a875fa 100644 --- a/functional_tests/test_v2_prompt_composer_card_logic.ts +++ b/functional_tests/test_v2_prompt_composer_card_logic.ts @@ -1,7 +1,7 @@ // test_v2_prompt_composer_card_logic.ts // // Runtime test for the attached-prompt card's composition and recovery rules. -// Version: 0.261.099 +// Version: 0.261.213 // Implemented in: 0.261.092 // Shared editor implemented in: 0.261.096 // Frozen prompt snapshots implemented in: 0.261.096 @@ -657,13 +657,19 @@ check('dispatchPlan carries the prompt into both the optimistic message and requ assert.equal(optimistic.metadata?.orchestration_turn_id, body.turn_id); assert.deepEqual(optimistic.metadata?.prompt_selection, promptSelectionMetadata(info)); observed = true; + // normalizePlan accepts only planner contract 2, and isPlanRunnable needs an enabled step. return new Response(`data: ${JSON.stringify({ type: 'orchestration_plan', done: true, plan: { plan_id: 'plan-1', run_id: 'run-1', turn_id: body.turn_id, conversation_id: body.conversation_id, user_id: 'author-1', revision: 0, + planner_contract_version: 2, intent: { summary: body.message, complexity: 'simple' }, - steps: [], assumptions: [], status: 'awaiting_approval', + steps: [{ + step_id: 's1', capability_id: 'compose', title: 'Answer', + arguments: {}, role: 'reason', estimated_cost: 'low', + }], + assumptions: [], status: 'awaiting_approval', approval: { mode: 'manual', state: 'pending' }, validation: { valid: true, errors: [], warnings: [], repairs: [] }, }, diff --git a/functional_tests/test_workflow_document_picker_recent_targets.py b/functional_tests/test_workflow_document_picker_recent_targets.py index 69a5d40b6..3ba8ba9c0 100644 --- a/functional_tests/test_workflow_document_picker_recent_targets.py +++ b/functional_tests/test_workflow_document_picker_recent_targets.py @@ -2,7 +2,7 @@ # test_workflow_document_picker_recent_targets.py """ Functional test for workflow document picker and recent workflow document targets. -Version: 0.250.225 +Version: 0.261.213 Implemented in: 0.241.188 Enhanced in: 0.250.225 @@ -11,10 +11,12 @@ document workflows can be saved and resolved at run time. """ +import ast import importlib import os import sys import types +from contextlib import contextmanager from pathlib import Path from test_support.versioning import assert_app_version_at_least @@ -23,8 +25,12 @@ APP_ROOT = REPO_ROOT / 'application' / 'single_app' sys.path.insert(0, str(APP_ROOT)) +DOCUMENT_ACTIONS_MODULE = 'functions_document_actions' +MODULES_INSTALLED_OVER_STUBS = [] -if 'functions_document_analysis' not in sys.modules: + +def build_document_action_stubs() -> dict: + """Build stand-ins for the service-backed modules functions_document_actions imports.""" document_analysis_stub = types.ModuleType('functions_document_analysis') document_analysis_stub.CHAT_DOCUMENT_ANALYSIS_MAX_DOCUMENTS = 3 document_analysis_stub.WORKFLOW_DOCUMENT_ANALYSIS_MAX_DOCUMENTS = 10 @@ -51,9 +57,7 @@ def normalize_document_analysis_targets(document_ids=None, max_documents=None, * } document_analysis_stub.normalize_document_analysis_targets = normalize_document_analysis_targets - sys.modules['functions_document_analysis'] = document_analysis_stub -if 'functions_search' not in sys.modules: search_stub = types.ModuleType('functions_search') def normalize_search_id_list(values=None): @@ -88,7 +92,36 @@ def normalize_search_top_n(top_n, default_top_n=12, max_top_n=50): search_stub.normalize_search_id_list = normalize_search_id_list search_stub.normalize_search_scope = normalize_search_scope search_stub.normalize_search_top_n = normalize_search_top_n - sys.modules['functions_search'] = search_stub + return { + 'functions_document_analysis': document_analysis_stub, + 'functions_search': search_stub, + } + + +@contextmanager +def document_actions_over_stubs(): + """Import functions_document_actions over stubs, then restore those sys.modules entries. + + Only the stubs and the module bound to them are restored. Third-party modules the + import loads stay loaded, because unloading compiled extensions mid-process is unsafe. + """ + stubs = build_document_action_stubs() + names = (*stubs, DOCUMENT_ACTIONS_MODULE) + previous = {name: sys.modules[name] for name in names if name in sys.modules} + try: + for name, stub in stubs.items(): + sys.modules.setdefault(name, stub) + document_actions = importlib.import_module(DOCUMENT_ACTIONS_MODULE) + MODULES_INSTALLED_OVER_STUBS.extend( + (name, sys.modules[name]) for name in names if name not in previous + ) + yield document_actions + finally: + for name in names: + if name in previous: + sys.modules[name] = previous[name] + else: + sys.modules.pop(name, None) WORKSPACE_TEMPLATE = APP_ROOT / 'templates' / 'workspace.html' @@ -104,6 +137,13 @@ def read_text(path: Path) -> str: return path.read_text(encoding='utf-8') +def function_source(source: str, function_name: str) -> str: + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.FunctionDef) and node.name == function_name: + return ast.get_source_segment(source, node) + raise AssertionError(f'{function_name} was not found in the workflow runner.') + + def test_workflow_modals_use_picker_contracts() -> None: print('Testing workflow modal picker contracts...') workspace_template = read_text(WORKSPACE_TEMPLATE) @@ -186,8 +226,11 @@ def test_workflow_picker_javascript_contracts() -> None: def test_recent_target_backend_normalization() -> None: print('Testing recent workflow target backend normalization...') - document_actions = importlib.import_module('functions_document_actions') + with document_actions_over_stubs() as document_actions: + assert_recent_target_normalization(document_actions) + +def assert_recent_target_normalization(document_actions) -> None: search_action = document_actions.normalize_document_action_config({ 'type': 'search', 'document_ids': [], @@ -283,6 +326,16 @@ def test_recent_target_backend_normalization() -> None: assert resolved_comparison_action['recent_targets_resolved'] is True +def test_document_action_stubs_do_not_outlive_their_test() -> None: + print('Testing that document action stubs are removed after use...') + with document_actions_over_stubs(): + pass + left_behind = sorted({ + name for name, module in MODULES_INSTALLED_OVER_STUBS if sys.modules.get(name) is module + }) + assert not left_behind, f'Modules installed over stubs are still in sys.modules: {left_behind}' + + def test_recent_target_runner_hooks() -> None: print('Testing recent target runner hooks...') workflow_runner = read_text(WORKFLOW_RUNNER_PY) @@ -311,7 +364,10 @@ def test_recent_target_runner_hooks() -> None: assert "resolved_action['left_document_id'] = document_ids[0]" in workflow_runner assert "resolved_action['right_document_ids'] = document_ids[1:]" in workflow_runner assert 'search_documents(' in workflow_runner - assert "'hybrid_citations': workflow_search_context.get('citations') or []" in workflow_runner + attach_search_context = function_source(workflow_runner, '_attach_workflow_search_context') + assert "list(search_context.get('citations') or [])" in attach_search_context + assert "'hybrid_citations': hybrid_citations," in attach_search_context + assert 'execution_result = _attach_workflow_search_context(execution_result, workflow_search_context)' in workflow_runner assert 'workflow = _apply_runtime_document_action_config(workflow, action_config)' in workflow_runner @@ -320,6 +376,7 @@ def run_tests() -> bool: test_workflow_modals_use_picker_contracts, test_workflow_picker_javascript_contracts, test_recent_target_backend_normalization, + test_document_action_stubs_do_not_outlive_their_test, test_recent_target_runner_hooks, ] results = [] diff --git a/ui_tests/test_v2_group_participant_invites.py b/ui_tests/test_v2_group_participant_invites.py index c11b664d7..2f0579f45 100644 --- a/ui_tests/test_v2_group_participant_invites.py +++ b/ui_tests/test_v2_group_participant_invites.py @@ -1,7 +1,7 @@ # test_v2_group_participant_invites.py """ Browser regression for React v2 group participant invitations. -Version: 0.261.106 +Version: 0.261.213 Implemented in: 0.261.106 Related issue and backend fix: microsoft/simplechat#1472, #1473. @@ -14,6 +14,7 @@ Run: python .\\ui_tests\\test_v2_group_participant_invites.py """ +import builtins from copy import deepcopy from pathlib import Path import re @@ -23,12 +24,15 @@ import pytest from playwright.sync_api import expect -# Resolve the existing browser and isolated backend harnesses for standalone runs. +# Resolve the existing browser and isolated backend harnesses, and the application +# modules that served messages pass through, for standalone runs. REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "application" / "single_app")) sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures" / "orchestration")) sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures")) sys.path.insert(0, str(REPO_ROOT / "functional_tests")) import harness_build as hb # noqa: E402 +from functions_chat_content_checks import strip_private_chat_checks # noqa: E402 from playwright_connection import connect_options # noqa: E402,F401 from test_group_collaboration_source_storage_fix import ( # noqa: E402 COLLABORATION_FILE, @@ -38,8 +42,10 @@ SECOND_INVITEE, SOURCE_ID, ConversionHarness, + global_reads, history_fixture, load_source_members, + production_functions, ) @@ -49,6 +55,11 @@ "display_name": "Directory Colleague", "email": "colleague@example.com", } +SERVED_HELPERS = {"serialize_collaboration_message", "_get_collaboration_display_role"} + + +def history_has_no_images(*args, **kwargs): + raise AssertionError("The participant history has no image messages to serialize.") class ParticipantApi: @@ -66,12 +77,18 @@ def __init__(self, storage): self.unexpected = [] self.expected_errors = set() self.fail_member_search = False - helpers = load_source_members( + self.helpers = load_source_members( str(COLLABORATION_FILE), - {"serialize_collaboration_message", "_get_collaboration_display_role"}, - namespace=self.backend.namespace, + SERVED_HELPERS, + namespace={ + **self.backend.namespace, + "strip_private_chat_checks": strip_private_chat_checks, + # The served history is text-only, so image serialization is unreachable. + "build_collaboration_image_url": history_has_no_images, + "_publicize_image_revisions": history_has_no_images, + }, ) - self.serialize_message = helpers["serialize_collaboration_message"] + self.serialize_message = self.helpers["serialize_collaboration_message"] def conversations(self): return [ @@ -334,5 +351,20 @@ def test_personal_invites_keep_the_personal_conversion_path(participant_page): assert not any(path.startswith("/api/groups/") for _, path in api.requests) +def test_participant_api_binds_every_global_its_served_helpers_read(): + api = ParticipantApi("regular") + helpers = dict(production_functions(api.helpers)) + unbound = { + name: sorted(global_reads(function.__code__) - set(api.helpers) - set(dir(builtins))) + for name, function in helpers.items() + } + unbound = {name: names for name, names in unbound.items() if names} + assert set(helpers) == SERVED_HELPERS + assert unbound == {}, ( + "Seed these production globals in ParticipantApi, or serving messages fails with " + f"NameError: {unbound}" + ) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/ui_tests/test_v2_orchestration_auto_open.py b/ui_tests/test_v2_orchestration_auto_open.py index 3695dcf8c..89d5b374f 100644 --- a/ui_tests/test_v2_orchestration_auto_open.py +++ b/ui_tests/test_v2_orchestration_auto_open.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """ UI test for the V2 chat orchestration auto-open asymmetry. -Version: 0.261.139 +Version: 0.261.213 Implemented in: 0.261.085 +Plan fixture identity re-synced with the controller in: 0.261.213 The drawer opens ITSELF when a plan reaches awaiting-approval only in `manual` mode, and only for the conversation on screen. This is a deliberate design rule, not an accident: manual approval is a @@ -17,14 +18,27 @@ * timed -> the drawer stays shut. * manual, but a different conversation is on screen -> the drawer stays shut. +The controller adopts a streamed plan only when it carries plan, run and turn ids and names the +conversation the turn was started for; it drops any other plan without touching the drawer. So the +mocked stream answers as the server does, naming the started conversation and echoing the turn id +the client minted, and every case first asserts that the plan was adopted. A dropped plan can never +pass a "drawer stays shut" check. + No Azure credentials and no server are needed: the plan and run streams are mocked in the browser and the controller and stores are the code that ships. The browser checks are skipped (reported, not failed) when application/v2_ui/node_modules is absent. + +Run: python ui_tests/test_v2_orchestration_auto_open.py + or python -m pytest ui_tests/test_v2_orchestration_auto_open.py -q """ import sys +import traceback +from contextlib import ExitStack from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures" / "orchestration")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "functional_tests")) @@ -32,13 +46,20 @@ from test_support.versioning import assert_app_version_at_least # noqa: E402 +pytestmark = pytest.mark.ui + IMPLEMENTED_IN = "0.261.085" +# Every plan is started for CONVERSATION; OTHER_CONVERSATION is a different thread put on screen. +CONVERSATION = "c1" +OTHER_CONVERSATION = "other" -_PAGE = None +def _plan(mode, conversation_id): + """A runnable plan for ``conversation_id`` in the given approval mode: a search, then the answer. -def _plan(mode): - """A runnable plan carrying the given approval mode, with a search step then the answer.""" + It carries no turn id on purpose. The client mints the turn id when the turn starts and the + server echoes the one it was sent, so the mocked plan stream in _DRIVE_PLAN stamps it on. + """ if mode == "auto": approval = {"mode": "auto", "timeout_seconds": 0, "state": "approved"} status = "approved" @@ -48,6 +69,7 @@ def _plan(mode): return { "plan_id": f"plan-{mode}", "run_id": f"run-{mode}", + "conversation_id": conversation_id, "planner_contract_version": 2, "intent": {"summary": f"A {mode} plan", "complexity": "simple"}, "steps": [ @@ -74,6 +96,7 @@ def _plan(mode): # Seed visibility, mock the plan (and run) streams, and drive a fresh plan through the controller. +# The result reports what the controller requested and adopted, so a dropped plan is visible. _DRIVE_PLAN = r""" async (spec) => { const H = window.OrchHarness; @@ -81,14 +104,19 @@ def _plan(mode): const { conv, visibleConv, mode, plan } = spec; const orch = H.stores.orchestration.useOrchestrationStore; const chat = H.stores.chat.useChatStore; - chat.setState({ activeConversationId: conv, drawerMode: null }); + chat.setState({ activeConversationId: conv, drawerMode: null, streamError: null }); orch.getState().setVisibleConversation(visibleConv); const encoder = new TextEncoder(); - window.fetch = (url) => { + const planRequests = []; + window.fetch = (url, options = {}) => { const requestUrl = String(url); if (requestUrl.includes('/api/v2/orchestration/plan')) { - const frame = 'data: ' + JSON.stringify({ type: 'orchestration_plan', plan }) + '\n\n'; + const request = typeof options.body === 'string' ? JSON.parse(options.body) : {}; + planRequests.push({ conversation_id: request.conversation_id, turn_id: request.turn_id }); + // As the server does, echo the turn id the client minted for this turn on the plan. + const streamed = { ...plan, turn_id: request.turn_id }; + const frame = 'data: ' + JSON.stringify({ type: 'orchestration_plan', plan: streamed }) + '\n\n'; const body = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(frame)); @@ -126,117 +154,132 @@ def _plan(mode): }); await new Promise((resolve) => setTimeout(resolve, 40)); + const plans = Object.values(orch.getState().plans); return { drawerMode: chat.getState().drawerMode, - planCount: Object.keys(orch.getState().plans).length, + streamError: chat.getState().streamError, + planCount: plans.length, + plans: plans.map((held) => ({ + plan_id: held.plan_id, + conversation_id: held.conversation_id, + turn_id: held.turn_id, + })), + planRequests, }; } """ +def _drive_plan(page, mode, visible_conversation): + """Start a fresh ``mode`` plan for CONVERSATION while ``visible_conversation`` is on screen.""" + return page.evaluate( + _DRIVE_PLAN, + { + "conv": CONVERSATION, + "visibleConv": visible_conversation, + "mode": mode, + "plan": _plan(mode, CONVERSATION), + }, + ) + + +def _assert_plan_adopted(result, mode): + """Assert the controller adopted the one streamed plan, for the conversation and turn it began. + + The controller drops a plan that lacks an id or names another conversation, and a dropped plan + leaves the drawer shut too. Checking adoption first keeps each drawer assertion meaningful. + """ + requests = result["planRequests"] + assert len(requests) == 1, f"the controller must request exactly one plan, saw {result!r}" + turn_id = requests[0]["turn_id"] + assert requests[0]["conversation_id"] == CONVERSATION and turn_id, ( + f"the plan request must name {CONVERSATION!r} and carry the client's turn id, saw {result!r}" + ) + assert result["planCount"] == 1, ( + f"the controller must adopt the streamed plan, but it holds {result['planCount']} plan(s) " + f"and reported {result['streamError']!r}" + ) + expected = {"plan_id": f"plan-{mode}", "conversation_id": CONVERSATION, "turn_id": turn_id} + assert result["plans"] == [expected], ( + f"the adopted plan must belong to {CONVERSATION!r} and the requested turn, saw {result!r}" + ) + + +def _report_page_errors(errors): + """Print uncaught page errors for diagnosis; as before, they are reported, not failed on.""" + if errors: + print("\nUncaught page errors observed during the run:") + for message in errors: + print(f" !! {message}") + + +@pytest.fixture(scope="module") +def orchestration_page(): + """One harness page shared by the module; skipped, not failed, without the v2_ui toolchain.""" + errors = [] + with ExitStack() as stack: + try: + page = stack.enter_context(hb.harness_page(collect_errors=errors)) + except hb.HarnessUnavailable as exc: + pytest.skip(f"skipped the browser-driven checks: {exc}") + else: + yield page + _report_page_errors(errors) + + def test_version_is_at_least_the_implementing_release(): """The auto-open rule shipped in IMPLEMENTED_IN, so the app must be at least that version.""" print("Testing the app version is at least the implementing release...") - try: - assert_app_version_at_least(IMPLEMENTED_IN) - print(f" ok config.py VERSION is at least {IMPLEMENTED_IN}") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - return False + assert_app_version_at_least(IMPLEMENTED_IN) + print(f" ok config.py VERSION is at least {IMPLEMENTED_IN}") -def test_manual_plan_opens_the_drawer(): +def test_manual_plan_opens_the_drawer(orchestration_page): """A manual plan for the visible conversation opens the drawer to plan mode by itself.""" print("Testing a manual plan auto-opens the drawer...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "c1", "mode": "manual", "plan": _plan("manual")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] == "plan", ( - f"manual must auto-open the drawer, saw {result['drawerMode']!r}" - ) - print(" ok the manual plan opened the drawer to plan mode") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback - - traceback.print_exc() - return False + result = _drive_plan(orchestration_page, "manual", visible_conversation=CONVERSATION) + _assert_plan_adopted(result, "manual") + assert result["drawerMode"] == "plan", ( + f"manual must auto-open the drawer, saw {result['drawerMode']!r}" + ) + print(" ok the manual plan opened the drawer to plan mode") -def test_auto_plan_leaves_the_drawer_shut(): +def test_auto_plan_leaves_the_drawer_shut(orchestration_page): """An auto plan is pre-approved and runs, but must not throw the drawer open.""" print("Testing an auto plan leaves the drawer shut...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "c1", "mode": "auto", "plan": _plan("auto")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] is None, ( - f"auto must leave the drawer shut, saw {result['drawerMode']!r}" - ) - print(" ok the auto plan ran without opening the drawer") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback - - traceback.print_exc() - return False + result = _drive_plan(orchestration_page, "auto", visible_conversation=CONVERSATION) + _assert_plan_adopted(result, "auto") + assert result["drawerMode"] is None, ( + f"auto must leave the drawer shut, saw {result['drawerMode']!r}" + ) + print(" ok the auto plan ran without opening the drawer") -def test_timed_plan_leaves_the_drawer_shut(): +def test_timed_plan_leaves_the_drawer_shut(orchestration_page): """A timed plan carries its countdown on the inline card, so the drawer stays shut.""" print("Testing a timed plan leaves the drawer shut...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "c1", "mode": "timed", "plan": _plan("timed")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] is None, ( - f"timed must leave the drawer shut, saw {result['drawerMode']!r}" - ) - print(" ok the timed plan left the drawer shut") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback + result = _drive_plan(orchestration_page, "timed", visible_conversation=CONVERSATION) + _assert_plan_adopted(result, "timed") + assert result["drawerMode"] is None, ( + f"timed must leave the drawer shut, saw {result['drawerMode']!r}" + ) + print(" ok the timed plan left the drawer shut") - traceback.print_exc() - return False +def test_manual_plan_for_another_conversation_stays_shut(orchestration_page): + """Even a manual plan must not yank a panel open over a different conversation on screen. -def test_manual_plan_for_another_conversation_stays_shut(): - """Even a manual plan must not yank a panel open over a different conversation on screen.""" + The plan is still adopted for the conversation it was started for; only the drawer stays shut. + """ print("Testing a manual plan for an off-screen conversation stays shut...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "other", "mode": "manual", "plan": _plan("manual")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] is None, ( - f"a plan for an off-screen conversation must not open the drawer, " - f"saw {result['drawerMode']!r}" - ) - print(" ok the off-screen manual plan did not open the drawer") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback - - traceback.print_exc() - return False + result = _drive_plan(orchestration_page, "manual", visible_conversation=OTHER_CONVERSATION) + _assert_plan_adopted(result, "manual") + assert result["drawerMode"] is None, ( + f"a plan for an off-screen conversation must not open the drawer, " + f"saw {result['drawerMode']!r}" + ) + print(" ok the off-screen manual plan did not open the drawer") PAGE_TESTS = [ @@ -247,27 +290,32 @@ def test_manual_plan_for_another_conversation_stays_shut(): ] +def _run(test, *args): + """Run one check for the script runner, reporting a failure rather than stopping the run.""" + try: + test(*args) + except Exception as exc: # noqa: BLE001 - every failure is reported, then the run carries on + print(f"Test failed: {exc}") + traceback.print_exc() + return False + return True + + def main(): - results = [test_version_is_at_least_the_implementing_release()] + results = [_run(test_version_is_at_least_the_implementing_release)] errors = [] try: with hb.harness_page(collect_errors=errors) as page: - global _PAGE - _PAGE = page for test in PAGE_TESTS: print(f"\nRunning {test.__name__}...") page.evaluate("() => window.OrchHarness.reset()") - results.append(test()) + results.append(_run(test, page)) except hb.HarnessUnavailable as exc: print(f"\n -- skipped the browser-driven checks: {exc}") results.extend([True] * len(PAGE_TESTS)) - if errors: - print("\nUncaught page errors observed during the run:") - for message in errors: - print(f" !! {message}") - + _report_page_errors(errors) print(f"\nResults: {sum(results)}/{len(results)} tests passed") return all(results) diff --git a/ui_tests/test_v2_workflow_loops.py b/ui_tests/test_v2_workflow_loops.py index b3b5f6d3a..8c45a42ef 100644 --- a/ui_tests/test_v2_workflow_loops.py +++ b/ui_tests/test_v2_workflow_loops.py @@ -1,7 +1,7 @@ # test_v2_workflow_loops.py """ Closed browser regressions for serial For each, exact Collect and explicit saved-record reporting. -Version: 0.261.127 +Version: 0.261.213 Implemented in: 0.261.117 Loads the real built local SPA and validates authoring payloads with production @@ -906,13 +906,20 @@ def test_revoked_record_refresh_clears_the_previous_page(workflow_loops_ui): expand_history(ui) page.get_by_role("button", name=f"Show execution attempts for {COLLECT_EXECUTION_ID}", exact=True).click() page.get_by_role("button", name="Load complete records", exact=True).click() - expect(page.get_by_role("list", name="Complete saved records", exact=True)).to_contain_text("Private brief") + records = page.get_by_role("list", name="Complete saved records", exact=True) + expect(records).to_contain_text("Finding from Private brief") ui.reject_next("GET", f"/api/user/workflows/{LOOP_WORKFLOW_ID}/runs/{LOOP_RUN_ID}/executions/{COLLECT_EXECUTION_ID}/attempts/1/records", status=403, error="Source access could not be confirmed.") panel = page.get_by_role("region", name="Complete record inspection", exact=True) panel.get_by_role("button", name="Refresh", exact=True).click() - expect(panel.get_by_role("alert").filter(has_text="no longer have access")).to_be_visible() - expect(page.get_by_role("list", name="Complete saved records", exact=True)).to_have_count(0) + # Lost access is escalated to the run, which removes every cached run detail, not only this page. + access_lost = page.get_by_role("alert").filter(has_text="Cached run details were removed") + expect(access_lost).to_be_visible() + expect(access_lost).to_contain_text("You no longer have access to this workflow run.") + assert not ui.failures, "The records refresh never received the revoked-access response." + expect(panel).to_have_count(0) + expect(records).to_have_count(0) + expect(page.get_by_text("Finding from Private brief", exact=False)).to_have_count(0) @pytest.mark.parametrize("group", [False, True])