From 31f0fa06c360cfefc948f484b9ff90b6419f15e7 Mon Sep 17 00:00:00 2001 From: marwan2232004 <118024824+marwan2232004@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:23:11 +0300 Subject: [PATCH 1/7] feat: added user edits in bdd --- src/models/bdd.py | 31 +- src/repositories/bdd_repo.py | 1 + src/services/assertions/scenario_context.py | 4 +- src/services/bdd/collect_features.py | 203 ++++++++ src/services/bdd/editor_steps.py | 66 +++ src/services/bdd/generate_mapping.py | 550 ++++++++++++++++++++ src/services/bdd/gherkin.py | 13 +- src/services/bdd/regression.py | 509 +++++------------- src/tasks/bdd.py | 31 +- src/worker.py | 30 +- 10 files changed, 1013 insertions(+), 425 deletions(-) create mode 100644 src/services/bdd/collect_features.py create mode 100644 src/services/bdd/editor_steps.py create mode 100644 src/services/bdd/generate_mapping.py diff --git a/src/models/bdd.py b/src/models/bdd.py index 32b3a6e..1debb03 100644 --- a/src/models/bdd.py +++ b/src/models/bdd.py @@ -2,23 +2,27 @@ from enum import Enum from typing import Any from dataclasses import dataclass -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class StepType(str, Enum): STATE = "STATE" TRANSITION = "TRANSITION" + DESIGN_CLASS = "DESIGN_CLASS" ASSERTION = "ASSERTION" ACTION_HOOK = "ACTION_HOOK" class FlowEditorStepKind(str, Enum): DESIGN_CLASS = "design-class" - DESIGN_OPERATION = "design-operation" ASSERTION = "assertion" ACTION_HOOK = "action-hook" - GROUP = "group" +FLOW_TO_STEP_TYPE = { + FlowEditorStepKind.DESIGN_CLASS: StepType.DESIGN_CLASS, + FlowEditorStepKind.ASSERTION: StepType.ASSERTION, + FlowEditorStepKind.ACTION_HOOK: StepType.ACTION_HOOK, +} class FlowEditorPositionEdge(str, Enum): BEFORE = "before" @@ -68,6 +72,23 @@ def validate_transition_ids(cls, value: list[str]) -> list[str]: raise ValueError("transition_ids cannot contain empty values") return value + @model_validator(mode="after") + def validate_editor_step_positions(self) -> "BddFlowInput": + transition_ids = set(self.transition_ids) + unknown = sorted( + { + step.position.transitionId + for step in self.editor_steps + if step.position.transitionId not in transition_ids + } + ) + if unknown: + raise ValueError( + "editor_steps reference transition ids outside transition_ids: " + + ", ".join(unknown) + ) + return self + class BddGenerationInput(BaseModel): graph_id: str = Field(min_length=1) @@ -192,6 +213,7 @@ class ResolvedFlow(BaseModel): flow_id: str | None = None checkpoint: ResolvedState transitions: list[ResolvedTransition] + editor_steps: list[FlowEditorDraftStep] = Field(default_factory=list) class StepPlan(BaseModel): @@ -223,7 +245,6 @@ class SemanticAssertion(BaseModel): definition: dict[str, Any] semantic: dict[str, Any] = Field(default_factory=dict) - @dataclass(frozen=True) class CompiledFeature: id: str @@ -240,5 +261,3 @@ class CompiledBdd: assertions: dict[str, dict] action_hooks: dict[str, dict] design_class: dict[str, Any] | None = None - feature_name: str | None = None - feature_text: str | None = None diff --git a/src/repositories/bdd_repo.py b/src/repositories/bdd_repo.py index 476af46..0ecf8b3 100644 --- a/src/repositories/bdd_repo.py +++ b/src/repositories/bdd_repo.py @@ -166,6 +166,7 @@ async def resolve_flows( flow_id=requested.flow_id, checkpoint=checkpoint, transitions=transitions, + editor_steps=requested.editor_steps ) ) return resolved diff --git a/src/services/assertions/scenario_context.py b/src/services/assertions/scenario_context.py index 8881509..b5149c6 100644 --- a/src/services/assertions/scenario_context.py +++ b/src/services/assertions/scenario_context.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from src.models.bdd import ResolvedFlow +from src.models.bdd import ResolvedFlow, ResolvedState from src.services.assertions.html_summarizer import HtmlSummary, summarize_html @@ -112,7 +112,7 @@ def build_scenario_contexts( return contexts -def _state_context(state, html_summary_max_chars: int) -> StateContext: +def _state_context(state: ResolvedState, html_summary_max_chars: int) -> StateContext: return StateContext( db_id=state.db_id, name=state.name, diff --git a/src/services/bdd/collect_features.py b/src/services/bdd/collect_features.py new file mode 100644 index 0000000..8d505f0 --- /dev/null +++ b/src/services/bdd/collect_features.py @@ -0,0 +1,203 @@ +from collections import Counter, defaultdict +from src.core.config import get_settings +from src.utils.helpers import jaccard +from urllib.parse import urlparse +from src.utils.helpers import words, upper_snake, title, url_area, jaccard +from src.models.bdd import ResolvedFlow + +settings = get_settings() + +GENERIC_FEATURE_WORDS = { + "action", + "click", + "flow", + "navigate", + "open", + "page", + "screen", + "transition", + "user", + "view", +} + + +def _unique_feature_names(names: list[str]) -> list[str]: + totals = Counter(names) + seen: Counter[str] = Counter() + unique: list[str] = [] + for name in names: + if totals[name] == 1: + unique.append(name) + continue + seen[name] += 1 + unique.append(f"{name} {seen[name]}") + return unique + + +def _destination_anchor(flow: ResolvedFlow) -> str: + end_state = flow.transitions[-1].to_state + area = url_area(end_state.url) + if area: + return f"url:{area}" + return f"state:{upper_snake(end_state.name, 'UNKNOWN')}" + + +def _group_centroid(group: list[int], profiles: list[set[str]]) -> set[str]: + centroid: set[str] = set() + for index in group: + centroid.update(profiles[index]) + return centroid + + +def _flow_labels(flow: ResolvedFlow) -> list[str]: + labels = [flow.checkpoint.name] + labels.extend(transition.name for transition in flow.transitions) + labels.append(flow.transitions[-1].to_state.name) + return labels + + +def _flow_urls(flow: ResolvedFlow) -> list[str]: + urls = [flow.checkpoint.url] + for transition in flow.transitions: + urls.extend([transition.from_state.url, transition.to_state.url]) + return urls + + +def _scenario_profile( + flow: ResolvedFlow, scenario_name: str, labels: list[str] +) -> set[str]: + tokens: set[str] = set() + labels = _flow_labels(flow) + labels.append(scenario_name) + labels.extend(transition.action for transition in flow.transitions) + for label in labels: + tokens.update(token.lower() for token in words(label)) + for url in _flow_urls(flow): + parsed = urlparse(url) + if parsed.hostname: + tokens.update(words(parsed.hostname.split(".")[0].lower())) + tokens.update(token.lower() for token in words(parsed.path)) + return {token for token in tokens if token not in GENERIC_FEATURE_WORDS} + + +def merge_groups( + groups: list[list[int]], + profiles: list[set[str]], + threshold: float, +) -> list[list[int]]: + merged = [list(group) for group in groups] + changed = True + while changed: + changed = False + best_pair: tuple[int, int] | None = None + best_score = threshold + for left_index in range(len(merged)): + left_profile = _group_centroid(merged[left_index], profiles) + for right_index in range(left_index + 1, len(merged)): + score = jaccard( + left_profile, + _group_centroid(merged[right_index], profiles), + ) + if score >= best_score: + best_score = score + best_pair = (left_index, right_index) + + if best_pair is None: + continue + + left_index, right_index = best_pair + merged[left_index].extend(merged[right_index]) + merged[left_index].sort() + del merged[right_index] + changed = True + + return merged + + +def merge_singletons( + groups: list[list[int]], + profiles: list[set[str]], + threshold: float, +) -> list[list[int]]: + merged = [list(group) for group in groups] + for group in list(merged): + if len(group) != 1 or group not in merged: + continue + singleton_index = group[0] + best_group: list[int] | None = None + best_score = threshold + for candidate in merged: + if candidate == group: + continue + score = jaccard( + profiles[singleton_index], + _group_centroid(candidate, profiles), + ) + if score >= best_score: + best_score = score + best_group = candidate + if best_group is None: + continue + best_group.append(singleton_index) + best_group.sort() + merged.remove(group) + return merged + + +def collect_features( + flows: list[ResolvedFlow], scenario_names: list[str] +) -> list[list[ResolvedFlow]]: + if not settings.bdd_split_features: + return [flows] + + feature_similarity_threshold = settings.bdd_feature_similarity_threshold + singleton_merge_threshold = settings.bdd_singleton_merge_threshold + + profiles = [ + _scenario_profile(flow, scenario_name) + for flow, scenario_name in zip(flows, scenario_names) + ] + anchored: dict[str, list[int]] = defaultdict(list) + for index, flow in enumerate(flows): + anchored[_destination_anchor(flow)].append(index) + + groups = list(anchored.values()) + groups = merge_groups(groups, profiles, feature_similarity_threshold) + groups = merge_singletons(groups, profiles, singleton_merge_threshold) + groups.sort(key=lambda group: min(group)) + return [[flows[index] for index in group] for group in groups] + + +def _infer_feature_name(flows: list[ResolvedFlow]) -> str: + label_tokens: list[set[str]] = [] + for flow in flows: + for label in _flow_labels(flow): + tokens = { + token.lower() + for token in words(label) + if token.lower() not in GENERIC_FEATURE_WORDS + } + if tokens: + label_tokens.append(tokens) + + shared = set.intersection(*label_tokens) if label_tokens else set() + if shared: + ordered = sorted(shared) + return f"{' '.join(word.capitalize() for word in ordered)} User Flows" + + hostnames = { + urlparse(flow.checkpoint.url).hostname + for flow in flows + if urlparse(flow.checkpoint.url).hostname + } + if len(hostnames) == 1: + hostname = next(iter(hostnames)) + application = hostname.split(".")[0].replace("-", " ") + if application: + return f"{title(application)} User Flows" + + return "Application User Flows" + + +def infer_feature_names(features): + return _unique_feature_names([_infer_feature_name(feature) for feature in features]) diff --git a/src/services/bdd/editor_steps.py b/src/services/bdd/editor_steps.py new file mode 100644 index 0000000..eb33ceb --- /dev/null +++ b/src/services/bdd/editor_steps.py @@ -0,0 +1,66 @@ +from collections import defaultdict +from typing import Literal + +from src.models.bdd import ( + FLOW_TO_STEP_TYPE, + FlowEditorDraftStep, + FlowEditorPositionEdge, + FlowEditorStepKind, + ResolvedFlow, + ResolvedState, + ResolvedTransition, + StepType, +) + +DESIGN_CLASS_ID = "scenarioData" +HookPhraseTiming = Literal["before", "after"] +HookMappingTiming = Literal["pre", "post"] + + +def editor_step_sort_key(step: FlowEditorDraftStep) -> tuple[str, int, int, str]: + edge_rank = 0 if step.position.edge == FlowEditorPositionEdge.BEFORE else 1 + return (step.position.transitionId, edge_rank, step.order, step.id) + + +def sorted_editor_steps( + steps: list[FlowEditorDraftStep], +) -> list[FlowEditorDraftStep]: + return sorted(steps, key=editor_step_sort_key) + + +def transition_by_input_id(flow: ResolvedFlow) -> dict[str, ResolvedTransition]: + return {transition.transition_id: transition for transition in flow.transitions} + + +def editor_steps_by_transition( + flow: ResolvedFlow, +) -> dict[str, dict[FlowEditorPositionEdge, list[FlowEditorDraftStep]]]: + grouped: dict[str, dict[FlowEditorPositionEdge, list[FlowEditorDraftStep]]] = ( + defaultdict(lambda: {FlowEditorPositionEdge.BEFORE: [], FlowEditorPositionEdge.AFTER: []}) + ) + for step in sorted_editor_steps(flow.editor_steps): + grouped[step.position.transitionId][step.position.edge].append(step) + return grouped + + +def hook_phrase_timing(edge: FlowEditorPositionEdge) -> HookPhraseTiming: + return "before" if edge == FlowEditorPositionEdge.BEFORE else "after" + + +def hook_mapping_timing(edge: FlowEditorPositionEdge) -> HookMappingTiming: + return "pre" if edge == FlowEditorPositionEdge.BEFORE else "post" + + +def target_state_for_editor_step( + step: FlowEditorDraftStep, + transition: ResolvedTransition, +) -> ResolvedState: + if step.position.edge == FlowEditorPositionEdge.BEFORE: + return transition.from_state + return transition.to_state + + +def executable_step_type(step: FlowEditorDraftStep) -> StepType: + if step.kind == FlowEditorStepKind.DESIGN_CLASS: + return StepType.ACTION_HOOK + return FLOW_TO_STEP_TYPE[step.kind] diff --git a/src/services/bdd/generate_mapping.py b/src/services/bdd/generate_mapping.py new file mode 100644 index 0000000..56da951 --- /dev/null +++ b/src/services/bdd/generate_mapping.py @@ -0,0 +1,550 @@ +from copy import deepcopy +import re +from typing import Any + +from src.utils.helpers import slug, pascal +from collections import defaultdict +from src.models.bdd import ( + BddTransitionAction, + FlowEditorDraftStep, + FlowEditorStepKind, + ResolvedFlow, + ResolvedState, + ResolvedTransition, + SemanticAssertion, + StepType, +) +from src.services.bdd.editor_steps import ( + DESIGN_CLASS_ID, + hook_mapping_timing, + sorted_editor_steps, + target_state_for_editor_step, + transition_by_input_id, +) + +ELEMENT_EXTRACT_SOURCES = { + "text", + "innerText", + "html", + "value", + "checked", + "visible", + "count", + "list", +} + + +def _class_name(identifier: str, prefix: str, suffix: str) -> str: + stem = identifier.removeprefix(f"{prefix}_") + return f"{pascal(stem)}{suffix}" + + +def _unique_locators(*groups: list[str]) -> list[str]: + locators: list[str] = [] + for group in groups: + for locator in group: + locator = str(locator or "").strip() + if locator and locator not in locators: + locators.append(locator) + return locators + + +def _effective_transition_actions( + transition: ResolvedTransition, +) -> list[BddTransitionAction]: + if transition.actions: + return transition.actions + if transition.locator_value.strip(): + return [ + BddTransitionAction( + selector=transition.locator_value, + action_type=transition.action_type, + ) + ] + return [] + + +def _transition_action_mapping( + action: BddTransitionAction, + state_id: str, +) -> dict[str, str]: + mapping = { + "type": action.action_type, + "stateId": state_id, + } + if action.action_type == "navigate": + mapping["url"] = action.value or action.selector + return mapping + + mapping["locatorKey"] = action.selector + if action.action_type in {"fill", "select"} and action.value is not None: + mapping["value"] = action.value + return mapping + + +def _action_locators_by_state_hash( + transitions: list[ResolvedTransition], +) -> dict[str, list[str]]: + locators: dict[str, list[str]] = defaultdict(list) + for transition in transitions: + for action in _effective_transition_actions(transition): + if action.action_type == "navigate": + continue + state_hash = transition.from_state.state_hash + if action.selector not in locators[state_hash]: + locators[state_hash].append(action.selector) + return locators + + +def generate_state_mappings( + states: list[ResolvedState], + state_ids: dict[str, str], + outgoing_locators: dict[str, list[str]], + transitions: list[ResolvedTransition], +) -> dict[str, dict]: + state_mappings: dict[str, dict] = {} + action_locators = _action_locators_by_state_hash(transitions) + for state in states: + state_id = state_ids[state.db_id] + locators = _unique_locators( + outgoing_locators.get(state.state_hash, []), + action_locators.get(state.state_hash, []), + ) + state_mappings[state_id] = { + "id": state_id, + "dbId": state.db_id, + "type": StepType.STATE.value, + "label": state.name, + "description": state.description, + "url": state.url, + "className": _class_name(state_id, "S", "State"), + "baselineDir": slug(state_id.removeprefix("S_")), + "dom": { + "elements": {locator: {"cssSelector": locator} for locator in locators} + }, + } + return state_mappings + + +def generate_transition_mappings( + transitions: list[ResolvedTransition], + transition_ids: dict[str, str], + state_ids: dict[str, str], +) -> dict[str, dict]: + transition_mappings: dict[str, dict] = {} + for transition in transitions: + transition_id = transition_ids[transition.db_id] + source_state_id = state_ids[transition.from_state.db_id] + transition_mappings[transition_id] = { + "id": transition_id, + "dbId": transition.db_id, + "type": StepType.TRANSITION.value, + "label": transition.name, + "description": transition.action, + "className": _class_name( + transition_id, + "T", + "Transition", + ), + "actions": [ + _transition_action_mapping(action, source_state_id) + for action in _effective_transition_actions(transition) + ], + } + return transition_mappings + + +def generate_assertion_mappings( + semantic_assertions_by_flow_index: dict[int, list[SemanticAssertion]], + state_ids: dict[str, str], +) -> dict[str, dict]: + mappings: dict[str, dict] = {} + for assertions in semantic_assertions_by_flow_index.values(): + for assertion in assertions: + assertion_id = assertion.id + + definition = dict(assertion.definition) + if definition.get("stateId") in state_ids: + definition["stateId"] = state_ids[definition.get("stateId")] + + mappings[assertion_id] = { + "id": assertion_id, + "dbId": assertion.db_id, + "type": StepType.ASSERTION.value, + "label": assertion.label, + "description": assertion.description, + "targetId": state_ids.get( + assertion.target_state_db_id, + assertion.target_state_db_id, + ), + "contextId": assertion.context_id, + "severity": assertion.severity, + "definition": definition, + "semantic": assertion.semantic, + } + return mappings + + +def generate_user_edit_mappings( + flows: list[ResolvedFlow], + state_ids: dict[str, str], + transition_ids: dict[str, str], +) -> tuple[dict[str, dict], dict[str, dict], dict[str, Any]]: + assertions: dict[str, dict] = {} + action_hooks: dict[str, dict] = {} + design_class = default_design_class_mapping() + + for flow in flows: + transitions_by_input_id = transition_by_input_id(flow) + for step in sorted_editor_steps(flow.editor_steps): + transition = transitions_by_input_id.get(step.position.transitionId) + if transition is None: + raise ValueError( + f"Editor step {step.id} references unknown transition " + f"{step.position.transitionId}" + ) + + transition_id = transition_ids[transition.db_id] + state_id = state_ids[target_state_for_editor_step(step, transition).db_id] + + if step.kind == FlowEditorStepKind.ASSERTION: + _add_user_assertion_mapping( + assertions, + design_class, + step, + transition_id, + state_id, + state_ids, + ) + continue + + if step.kind == FlowEditorStepKind.ACTION_HOOK: + _add_action_hook_mapping( + action_hooks, + step, + transition_id, + state_id, + _normalise_definition(step.definition, state_id, state_ids), + ) + continue + + if step.kind == FlowEditorStepKind.DESIGN_CLASS: + operation = _design_operation_from_step(step, state_id, design_class) + _ensure_unique(design_class["operations"], step.id, "design operation") + design_class["operations"][step.id] = operation + _add_action_hook_mapping( + action_hooks, + step, + transition_id, + state_id, + {"type": "design-operation", "operationId": step.id}, + ) + + return assertions, action_hooks, design_class + + +def default_design_class_mapping() -> dict[str, Any]: + return { + "id": DESIGN_CLASS_ID, + "label": "Scenario Data", + "description": "Single scenario data store for generated regression flows.", + "store": {}, + "extracts": {}, + "expressions": {}, + "functions": {}, + "assertionFunctions": {}, + "operations": {}, + "overwritable": True, + } + + +def _add_user_assertion_mapping( + assertions: dict[str, dict], + design_class: dict[str, Any], + step: FlowEditorDraftStep, + transition_id: str, + state_id: str, + state_ids: dict[str, str], +) -> None: + _ensure_unique(assertions, step.id, "assertion") + definition = deepcopy(step.definition or {}) + + if definition.get("type") == "function": + function_id = _function_id(definition, step) + assertion_function = { + "description": step.label or f"User assertion {step.id}", + } + if definition.get("code"): + assertion_function["code"] = deepcopy(definition["code"]) + if definition.get("severity"): + assertion_function["severity"] = definition["severity"] + _put_unique( + design_class["assertionFunctions"], + function_id, + assertion_function, + "assertion function", + ) + + mapped_definition = { + "type": "user-assertion", + "functionId": function_id, + } + if definition.get("args") is not None: + mapped_definition["args"] = deepcopy(definition["args"]) + else: + mapped_definition = _normalise_definition(definition, state_id, state_ids) + + assertions[step.id] = { + "id": step.id, + "type": StepType.ASSERTION.value, + "label": step.label or f"User assertion {step.id}", + "description": step.label or "", + "targetId": state_id, + "contextId": transition_id, + "severity": definition.get("severity", "blocking"), + "definition": mapped_definition, + "editorStep": _editor_step_metadata(step), + } + + +def _add_action_hook_mapping( + action_hooks: dict[str, dict], + step: FlowEditorDraftStep, + transition_id: str, + state_id: str, + definition: dict[str, Any], +) -> None: + _ensure_unique(action_hooks, step.id, "action hook") + action_hooks[step.id] = { + "id": step.id, + "type": StepType.ACTION_HOOK.value, + "label": step.label or f"User edit {step.id}", + "description": step.label or "", + "timing": hook_mapping_timing(step.position.edge), + "targetType": "transition", + "targetId": transition_id, + "contextId": transition_id, + "order": step.order, + "enabled": True, + "definition": definition, + "editorStep": _editor_step_metadata(step), + } + + +def _design_operation_from_step( + step: FlowEditorDraftStep, + state_id: str, + design_class: dict[str, Any], +) -> dict[str, Any]: + definition = deepcopy(step.definition or {}) + + if definition.get("type") == "function": + function_id = _function_id(definition, step) + design_function = { + "description": step.label or f"User design function {function_id}", + } + if definition.get("code"): + design_function["code"] = deepcopy(definition["code"]) + _put_unique( + design_class["functions"], + function_id, + design_function, + "design function", + ) + + operation: dict[str, Any] = { + "type": "call-function", + "functionId": function_id, + } + if definition.get("args") is not None: + operation["args"] = _normalise_design_values( + definition["args"], + step, + state_id, + design_class, + "args", + ) + if definition.get("assignTo"): + operation["assignTo"] = definition["assignTo"] + _ensure_store_slot(design_class, definition["assignTo"], step.label) + if step.label: + operation["description"] = step.label + return operation + + operation = _normalise_design_values( + definition, + step, + state_id, + design_class, + "operation", + ) + if step.label and isinstance(operation, dict): + operation.setdefault("description", step.label) + if isinstance(operation, dict): + key = operation.get("key") + if isinstance(key, str) and key: + _ensure_store_slot(design_class, key, step.label) + return ( + operation + if isinstance(operation, dict) + else {"type": "set", "value": operation} + ) + + +def _normalise_definition( + definition: dict[str, Any], + state_id: str, + state_ids: dict[str, str], +) -> dict[str, Any]: + normalised = _replace_state_refs(deepcopy(definition or {}), state_ids) + _inject_state_id(normalised, state_id) + return normalised + + +def _replace_state_refs(value: Any, state_ids: dict[str, str]) -> Any: + if isinstance(value, list): + return [_replace_state_refs(item, state_ids) for item in value] + if not isinstance(value, dict): + return value + replaced: dict[str, Any] = {} + for key, item in value.items(): + if key == "stateId" and isinstance(item, str): + replaced[key] = state_ids.get(item, item) + else: + replaced[key] = _replace_state_refs(item, state_ids) + return replaced + + +def _inject_state_id(value: Any, state_id: str) -> None: + if isinstance(value, list): + for item in value: + _inject_state_id(item, state_id) + return + if not isinstance(value, dict): + return + + if value.get("type") in {"element", "element-interaction"} and not value.get( + "stateId" + ): + value["stateId"] = state_id + + for item in value.values(): + _inject_state_id(item, state_id) + + +def _normalise_design_values( + value: Any, + step: FlowEditorDraftStep, + state_id: str, + design_class: dict[str, Any], + path: str, +) -> Any: + if isinstance(value, list): + return [ + _normalise_design_values( + item, step, state_id, design_class, f"{path}_{index}" + ) + for index, item in enumerate(value) + ] + if not isinstance(value, dict): + return value + + if value.get("source") == "element": + return _element_value_as_extract(value, step, state_id, design_class, path) + + return { + key: _normalise_design_values( + item, + step, + state_id, + design_class, + f"{path}_{key}", + ) + for key, item in value.items() + } + + +def _element_value_as_extract( + value: dict[str, Any], + step: FlowEditorDraftStep, + state_id: str, + design_class: dict[str, Any], + path: str, +) -> dict[str, str]: + selector = _first_text( + value.get("selector"), + value.get("locatorKey"), + value.get("cssSelector"), + step.element.selector if step.element else None, + ) + if not selector: + raise ValueError( + f"Design class editor step {step.id} is missing an element selector" + ) + + extract_id = _safe_identifier(f"{step.id}_{path}") + source_hint = _first_text(value.get("attribute"), value.get("token"), "text") + extract: dict[str, Any] = { + "stateId": state_id, + "locator": {"cssSelector": selector}, + "source": ( + source_hint if source_hint in ELEMENT_EXTRACT_SOURCES else "attribute" + ), + "description": step.label or f"Extract for {step.id}", + } + if extract["source"] == "attribute": + extract["attributeName"] = source_hint + + _put_unique(design_class["extracts"], extract_id, extract, "design extract") + return {"from": extract_id} + + +def _function_id(definition: dict[str, Any], step: FlowEditorDraftStep) -> str: + return _first_text(definition.get("functionId"), step.id) or step.id + + +def _ensure_store_slot( + design_class: dict[str, Any], + key: str, + label: str, +) -> None: + design_class["store"].setdefault( + key, + { + "reset": "scenario", + "description": label or f"User-edited value {key}", + }, + ) + + +def _editor_step_metadata(step: FlowEditorDraftStep) -> dict[str, Any]: + return step.model_dump(mode="json", exclude_none=True) + + +def _ensure_unique(mapping: dict[str, Any], key: str, label: str) -> None: + if key in mapping: + raise ValueError(f"Duplicate {label} id from editor steps: {key}") + + +def _put_unique( + mapping: dict[str, Any], + key: str, + value: dict[str, Any], + label: str, +) -> None: + existing = mapping.get(key) + if existing is not None and existing != value: + raise ValueError(f"Conflicting {label} definition for id: {key}") + mapping[key] = value + + +def _first_text(*values: Any) -> str: + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _safe_identifier(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") + return safe or "editor_value" diff --git a/src/services/bdd/gherkin.py b/src/services/bdd/gherkin.py index 6efccd4..9fa17c0 100644 --- a/src/services/bdd/gherkin.py +++ b/src/services/bdd/gherkin.py @@ -15,6 +15,10 @@ def _render_transition(step: StepPlan) -> str: return f'{step.keyword} I perform transition "{step.id}"' +def _render_design_class(step: StepPlan) -> str: + return f'{step.keyword} I use design class "{step.id}"' + + def _render_assertion(step: StepPlan) -> str: return f'{step.keyword} I assert "{step.id}"' @@ -27,17 +31,14 @@ def _render_action_hook(step: StepPlan) -> str: DEFAULT_STEP_RENDERERS: dict[StepType, StepRenderer] = { StepType.STATE: _render_state, StepType.TRANSITION: _render_transition, + StepType.DESIGN_CLASS: _render_design_class, StepType.ASSERTION: _render_assertion, StepType.ACTION_HOOK: _render_action_hook, } -def render_feature( - plan: FeaturePlan, - renderers: dict[StepType, StepRenderer] | None = None, -) -> str: +def render_feature(plan: FeaturePlan) -> str: """Render a typed feature plan using fixed, parser-compatible phrases.""" - step_renderers = {**DEFAULT_STEP_RENDERERS, **(renderers or {})} lines = [f"Feature: {plan.name}"] for scenario in plan.scenarios: @@ -46,7 +47,7 @@ def render_feature( lines.append(f" # Flow ID: {scenario.flow_id}") lines.append(f" Scenario: {scenario.name}") for step in scenario.steps: - renderer = step_renderers.get(step.type) + renderer = DEFAULT_STEP_RENDERERS.get(step.type) if renderer is None: raise ValueError(f"No renderer registered for {step.type}") lines.append(f" {renderer(step)}") diff --git a/src/services/bdd/regression.py b/src/services/bdd/regression.py index 0bdd1a6..38385a6 100644 --- a/src/services/bdd/regression.py +++ b/src/services/bdd/regression.py @@ -1,11 +1,13 @@ +from __future__ import annotations + from collections import Counter, defaultdict -from urllib.parse import urlparse -from src.utils.helpers import words, title, upper_snake, pascal, slug, url_area, jaccard +from typing import TYPE_CHECKING + from src.models.bdd import ( - BddTransitionAction, CompiledFeature, CompiledBdd, FeaturePlan, + FlowEditorStepKind, ResolvedFlow, ResolvedState, ResolvedTransition, @@ -13,8 +15,26 @@ SemanticAssertion, StepPlan, StepType, + FlowEditorPositionEdge, ) +from src.utils.helpers import title, upper_snake from src.services.bdd.gherkin import render_feature +from src.services.bdd.collect_features import collect_features, infer_feature_names +from src.services.bdd.generate_mapping import ( + generate_state_mappings, + generate_transition_mappings, + generate_assertion_mappings, + generate_user_edit_mappings, +) + +if TYPE_CHECKING: + from src.services.assertions import SemanticAssertionService +from src.services.bdd.editor_steps import ( + DESIGN_CLASS_ID, + editor_steps_by_transition, + executable_step_type, + hook_phrase_timing, +) GENERIC_FEATURE_WORDS = { "action", @@ -66,113 +86,6 @@ def _assign_ids(entities, prefix: str) -> dict[str, str]: return assigned -def _class_name(identifier: str, prefix: str, suffix: str) -> str: - stem = identifier.removeprefix(f"{prefix}_") - return f"{pascal(stem)}{suffix}" - - -def _effective_transition_actions( - transition: ResolvedTransition, -) -> list[BddTransitionAction]: - if transition.actions: - return transition.actions - if transition.locator_value.strip(): - return [ - BddTransitionAction( - selector=transition.locator_value, - action_type=transition.action_type, - ) - ] - return [] - - -def _transition_action_mapping( - action: BddTransitionAction, - state_id: str, -) -> dict[str, str]: - mapping = { - "type": action.action_type, - "stateId": state_id, - } - if action.action_type == "navigate": - mapping["url"] = action.value or action.selector - return mapping - - mapping["locatorKey"] = action.selector - if action.action_type in {"fill", "select"} and action.value is not None: - mapping["value"] = action.value - return mapping - - -def _action_locators_by_state_hash( - transitions: list[ResolvedTransition], -) -> dict[str, list[str]]: - locators: dict[str, list[str]] = defaultdict(list) - for transition in transitions: - for action in _effective_transition_actions(transition): - if action.action_type == "navigate": - continue - state_hash = transition.from_state.state_hash - if action.selector not in locators[state_hash]: - locators[state_hash].append(action.selector) - return locators - - -def _unique_locators(*groups: list[str]) -> list[str]: - locators: list[str] = [] - for group in groups: - for locator in group: - locator = str(locator or "").strip() - if locator and locator not in locators: - locators.append(locator) - return locators - - -def _flow_labels(flow: ResolvedFlow) -> list[str]: - labels = [flow.checkpoint.name] - labels.extend(transition.name for transition in flow.transitions) - labels.append(flow.transitions[-1].to_state.name) - return labels - - -def _flow_urls(flow: ResolvedFlow) -> list[str]: - urls = [flow.checkpoint.url] - for transition in flow.transitions: - urls.extend([transition.from_state.url, transition.to_state.url]) - return urls - - -def infer_feature_name(flows: list[ResolvedFlow]) -> str: - label_tokens: list[set[str]] = [] - for flow in flows: - for label in _flow_labels(flow): - tokens = { - token.lower() - for token in words(label) - if token.lower() not in GENERIC_FEATURE_WORDS - } - if tokens: - label_tokens.append(tokens) - - shared = set.intersection(*label_tokens) if label_tokens else set() - if shared: - ordered = sorted(shared) - return f"{' '.join(word.capitalize() for word in ordered)} User Flows" - - hostnames = { - urlparse(flow.checkpoint.url).hostname - for flow in flows - if urlparse(flow.checkpoint.url).hostname - } - if len(hostnames) == 1: - hostname = next(iter(hostnames)) - application = hostname.split(".")[0].replace("-", " ") - if application: - return f"{title(application)} User Flows" - - return "Application User Flows" - - def _base_scenario_name(flow: ResolvedFlow) -> str: labels = [ title(transition.name) for transition in flow.transitions if transition.name @@ -201,176 +114,91 @@ def _scenario_names(flows: list[ResolvedFlow]) -> list[str]: return names -def scenario_names_for_flows(flows: list[ResolvedFlow]) -> list[str]: - return _scenario_names(flows) - - -def _scenario_profile(flow: ResolvedFlow, scenario_name: str) -> set[str]: - tokens: set[str] = set() - labels = _flow_labels(flow) - labels.append(scenario_name) - labels.extend(transition.action for transition in flow.transitions) - for label in labels: - tokens.update(token.lower() for token in words(label)) - for url in _flow_urls(flow): - parsed = urlparse(url) - if parsed.hostname: - tokens.update(words(parsed.hostname.split(".")[0].lower())) - tokens.update(token.lower() for token in words(parsed.path)) - return {token for token in tokens if token not in GENERIC_FEATURE_WORDS} - - -def _destination_anchor(flow: ResolvedFlow) -> str: - end_state = flow.transitions[-1].to_state - area = url_area(end_state.url) - if area: - return f"url:{area}" - return f"state:{upper_snake(end_state.name, 'UNKNOWN')}" - - -def _group_centroid(group: list[int], profiles: list[set[str]]) -> set[str]: - centroid: set[str] = set() - for index in group: - centroid.update(profiles[index]) - return centroid - - -def _merge_groups( - groups: list[list[int]], - profiles: list[set[str]], - threshold: float, -) -> list[list[int]]: - merged = [list(group) for group in groups] - changed = True - while changed: - changed = False - best_pair: tuple[int, int] | None = None - best_score = threshold - for left_index in range(len(merged)): - left_profile = _group_centroid(merged[left_index], profiles) - for right_index in range(left_index + 1, len(merged)): - score = jaccard( - left_profile, - _group_centroid(merged[right_index], profiles), - ) - if score >= best_score: - best_score = score - best_pair = (left_index, right_index) - - if best_pair is None: - continue - - left_index, right_index = best_pair - merged[left_index].extend(merged[right_index]) - merged[left_index].sort() - del merged[right_index] - changed = True - - return merged - - -def _merge_singletons( - groups: list[list[int]], - profiles: list[set[str]], - threshold: float, -) -> list[list[int]]: - merged = [list(group) for group in groups] - for group in list(merged): - if len(group) != 1 or group not in merged: - continue - singleton_index = group[0] - best_group: list[int] | None = None - best_score = threshold - for candidate in merged: - if candidate == group: - continue - score = jaccard( - profiles[singleton_index], - _group_centroid(candidate, profiles), - ) - if score >= best_score: - best_score = score - best_group = candidate - if best_group is None: - continue - best_group.append(singleton_index) - best_group.sort() - merged.remove(group) - return merged - - -def _feature_groups( - flows: list[ResolvedFlow], - split_features: bool, - feature_similarity_threshold: float, - singleton_merge_threshold: float, -) -> list[list[ResolvedFlow]]: - if not split_features: - return [flows] - - scenario_names = _scenario_names(flows) - profiles = [ - _scenario_profile(flow, scenario_name) - for flow, scenario_name in zip(flows, scenario_names) - ] - anchored: dict[str, list[int]] = defaultdict(list) - for index, flow in enumerate(flows): - anchored[_destination_anchor(flow)].append(index) - - groups = list(anchored.values()) - groups = _merge_groups(groups, profiles, feature_similarity_threshold) - groups = _merge_singletons(groups, profiles, singleton_merge_threshold) - groups.sort(key=lambda group: min(group)) - return [[flows[index] for index in group] for group in groups] - - -def _unique_feature_names(names: list[str]) -> list[str]: - totals = Counter(names) - seen: Counter[str] = Counter() - unique: list[str] = [] - for name in names: - if totals[name] == 1: - unique.append(name) - continue - seen[name] += 1 - unique.append(f"{name} {seen[name]}") - return unique - - def _build_feature_plan( + feature_name: str, flows: list[ResolvedFlow], state_ids: dict[str, str], transition_ids: dict[str, str], flow_to_index: dict[int, int], - semantic_assertions_by_flow_index: dict[int, list[SemanticAssertion]], + semantic_assertions: dict[int, list[SemanticAssertion]], ) -> FeaturePlan: scenarios: list[ScenarioPlan] = [] + + def make_edit_step(edit) -> StepPlan: + metadata = {} + if executable_step_type(edit) == StepType.ACTION_HOOK: + metadata["timing"] = hook_phrase_timing(edit.position.edge) + return StepPlan( + type=executable_step_type(edit), + id=edit.id, + keyword="And", + metadata=metadata, + ) + for flow, scenario_name in zip(flows, _scenario_names(flows)): - steps = [ + edits_by_transition = editor_steps_by_transition(flow) + steps: list[StepPlan] = [] + if any(edit.kind == FlowEditorStepKind.DESIGN_CLASS for edit in flow.editor_steps): + steps.append( + StepPlan( + type=StepType.DESIGN_CLASS, + id=DESIGN_CLASS_ID, + keyword="Given", + ) + ) + + steps.append( StepPlan( type=StepType.STATE, id=state_ids[flow.checkpoint.db_id], keyword="Given", metadata={"tense": "current"}, ) - ] - for index, transition in enumerate(flow.transitions): + ) + + for transition in flow.transitions: + transition_edits = edits_by_transition.get( + transition.transition_id, + { + FlowEditorPositionEdge.BEFORE: [], + FlowEditorPositionEdge.AFTER: [], + }, + ) + + for edit in transition_edits[FlowEditorPositionEdge.BEFORE]: + if edit.kind == FlowEditorStepKind.ASSERTION: + steps.append(make_edit_step(edit)) + steps.append( StepPlan( type=StepType.TRANSITION, id=transition_ids[transition.db_id], - keyword="When" if index == 0 else "And", + keyword="When", ) ) - steps.append( - StepPlan( - type=StepType.STATE, - id=state_ids[flow.transitions[-1].to_state.db_id], - keyword="Then", - metadata={"tense": "expected"}, + + for edit in transition_edits[FlowEditorPositionEdge.BEFORE]: + if edit.kind != FlowEditorStepKind.ASSERTION: + steps.append(make_edit_step(edit)) + + for edit in transition_edits[FlowEditorPositionEdge.AFTER]: + if edit.kind != FlowEditorStepKind.ASSERTION: + steps.append(make_edit_step(edit)) + + steps.append( + StepPlan( + type=StepType.STATE, + id=state_ids[transition.to_state.db_id], + keyword="Then", + metadata={"tense": "expected"}, + ) ) - ) - for assertion in semantic_assertions_by_flow_index.get( + + for edit in transition_edits[FlowEditorPositionEdge.AFTER]: + if edit.kind == FlowEditorStepKind.ASSERTION: + steps.append(make_edit_step(edit)) + + for assertion in semantic_assertions.get( flow_to_index[id(flow)], [], ): @@ -389,16 +217,13 @@ def _build_feature_plan( ) ) - return FeaturePlan(name=infer_feature_name(flows), scenarios=scenarios) + return FeaturePlan(name=feature_name, scenarios=scenarios) -def compile_bdd( +async def compile_bdd( flows: list[ResolvedFlow], outgoing_locators: dict[str, list[str]], - semantic_assertions_by_flow_index: dict[int, list[SemanticAssertion]] | None = None, - split_features: bool = False, - feature_similarity_threshold: float = 0.42, - singleton_merge_threshold: float = 0.25, + semantic_assertion_service: SemanticAssertionService | None = None, ) -> CompiledBdd: """Compile resolved graph flows into Gherkin and regression mappings.""" if not flows: @@ -407,133 +232,63 @@ def compile_bdd( states, transitions = _unique_entities(flows) state_ids = _assign_ids(states, "S") transition_ids = _assign_ids(transitions, "T") - action_locators = _action_locators_by_state_hash(transitions) flow_to_index = {id(flow): index for index, flow in enumerate(flows)} - semantic_assertions_by_flow_index = semantic_assertions_by_flow_index or {} - flow_groups = _feature_groups( - flows, - split_features, - feature_similarity_threshold, - singleton_merge_threshold, - ) + + scenario_names = _scenario_names(flows) + semantic_assertions = ( + await semantic_assertion_service.generate(flows, scenario_names) + if semantic_assertion_service + else {} + ) or {} + + flow_groups = collect_features(flows, scenario_names=scenario_names) + feature_names = infer_feature_names(flow_groups) + plans = [ _build_feature_plan( + name, group, state_ids, transition_ids, flow_to_index, - semantic_assertions_by_flow_index, + semantic_assertions, ) - for group in flow_groups + for name, group in zip(feature_names, flow_groups) ] - feature_names = _unique_feature_names([plan.name for plan in plans]) + + user_assertions, action_hooks, design_class = generate_user_edit_mappings( + flows, + state_ids, + transition_ids, + ) + assertions = generate_assertion_mappings(semantic_assertions, state_ids) + duplicate_assertion_ids = set(assertions).intersection(user_assertions) + if duplicate_assertion_ids: + raise ValueError( + "User editor assertions conflict with generated assertion ids: " + + ", ".join(sorted(duplicate_assertion_ids)) + ) + assertions.update(user_assertions) + features = [ CompiledFeature( - id=f"F_{upper_snake(feature_name, f'FEATURE_{index + 1}')}", - feature_name=feature_name, - feature_text=render_feature( - FeaturePlan( - name=feature_name, - scenarios=plan.scenarios, - ) - ), + id=f"F_{upper_snake(plan.name, f'FEATURE_{index + 1}')}", + feature_name=plan.name, + feature_text=render_feature(plan), scenario_names=[scenario.name for scenario in plan.scenarios], ) - for index, (feature_name, plan) in enumerate(zip(feature_names, plans)) + for index, plan in enumerate(plans) ] - state_mappings: dict[str, dict] = {} - for state in states: - state_id = state_ids[state.db_id] - locators = _unique_locators( - outgoing_locators.get(state.state_hash, []), - action_locators.get(state.state_hash, []), - ) - state_mappings[state_id] = { - "id": state_id, - "dbId": state.db_id, - "type": StepType.STATE.value, - "label": state.name, - "description": state.description, - "url": state.url, - "className": _class_name(state_id, "S", "State"), - "baselineDir": slug(state_id.removeprefix("S_")), - "dom": { - "elements": {locator: {"cssSelector": locator} for locator in locators} - }, - } - - transition_mappings: dict[str, dict] = {} - for transition in transitions: - transition_id = transition_ids[transition.db_id] - source_state_id = state_ids[transition.from_state.db_id] - transition_mappings[transition_id] = { - "id": transition_id, - "dbId": transition.db_id, - "type": StepType.TRANSITION.value, - "label": transition.name, - "description": transition.action, - "className": _class_name( - transition_id, - "T", - "Transition", - ), - "actions": [ - _transition_action_mapping(action, source_state_id) - for action in _effective_transition_actions(transition) - ], - } - - assertion_mappings = _assertion_mappings( - semantic_assertions_by_flow_index, - state_ids, - ) - return CompiledBdd( features=features, - states=state_mappings, - transitions=transition_mappings, - assertions=assertion_mappings, - feature_name=features[0].feature_name if not split_features else None, - feature_text=features[0].feature_text if not split_features else None, + states=generate_state_mappings( + states, state_ids, outgoing_locators, transitions + ), + transitions=generate_transition_mappings( + transitions, transition_ids, state_ids + ), + assertions=assertions, + action_hooks=action_hooks, + design_class=design_class, ) - - -def _assertion_mappings( - semantic_assertions_by_flow_index: dict[int, list[SemanticAssertion]], - state_ids: dict[str, str], -) -> dict[str, dict]: - mappings: dict[str, dict] = {} - for assertions in semantic_assertions_by_flow_index.values(): - for assertion in assertions: - assertion_id = assertion.id - mappings[assertion_id] = { - "id": assertion_id, - "dbId": assertion.db_id, - "type": StepType.ASSERTION.value, - "label": assertion.label, - "description": assertion.description, - "targetId": state_ids.get( - assertion.target_state_db_id, - assertion.target_state_db_id, - ), - "contextId": assertion.context_id, - "severity": assertion.severity, - "definition": _translate_assertion_definition( - assertion.definition, - state_ids, - ), - "semantic": assertion.semantic, - } - return mappings - - -def _translate_assertion_definition( - definition: dict, - state_ids: dict[str, str], -) -> dict: - translated = dict(definition) - state_id = translated.get("stateId") - if state_id in state_ids: - translated["stateId"] = state_ids[state_id] - return translated diff --git a/src/tasks/bdd.py b/src/tasks/bdd.py index b5f64a1..184a3df 100644 --- a/src/tasks/bdd.py +++ b/src/tasks/bdd.py @@ -10,7 +10,7 @@ from src.models.bdd import BddGenerationInput from src.repositories.bdd_repo import BddRepository from src.services.assertions import SemanticAssertionService -from src.services.bdd.regression import compile_bdd, scenario_names_for_flows +from src.services.bdd.regression import compile_bdd settings = get_settings() logger = logging.getLogger("arq.worker.bdd") @@ -155,22 +155,13 @@ async def task_generate_bdd(ctx: dict, payload: dict) -> dict: graph_id, state_hashes, ) - - semantic_assertions_by_flow_index = await SemanticAssertionService( - settings - ).generate( - flows, - scenario_names_for_flows(flows), - ) - - compiled = compile_bdd( + + compiled = await compile_bdd( flows, outgoing_locators, - semantic_assertions_by_flow_index=semantic_assertions_by_flow_index, - split_features=settings.bdd_split_features, - feature_similarity_threshold=settings.bdd_feature_similarity_threshold, - singleton_merge_threshold=settings.bdd_singleton_merge_threshold, + SemanticAssertionService(settings), ) + logger.info( f"[BDD:{graph_id}] Generated {len(compiled.features)} feature(s) " f"with {len(flows)} scenarios" @@ -192,21 +183,23 @@ async def task_generate_bdd(ctx: dict, payload: dict) -> dict: "states": compiled.states, "transitions": compiled.transitions, "assertions": compiled.assertions, - "action_hooks": {}, + "action_hooks": compiled.action_hooks, + "design_class": compiled.design_class, "flow_ids": flow_ids, } + if compiled.features: + result_payload["feature_name"] = compiled.features[0].feature_name + result_payload["feature_text"] = compiled.features[0].feature_text + if request.regression_codebase_id: result_payload["regression_codebase_id"] = request.regression_codebase_id if request.codegen_config: result_payload["codegen_config"] = request.codegen_config - if compiled.feature_name is not None and compiled.feature_text is not None: - result_payload["feature_name"] = compiled.feature_name - result_payload["feature_text"] = compiled.feature_text - save_result_payload(result_payload, "artifacts") + await _enqueue_bullmq_job( ctx["redis"], BULLMQ_QUEUE, diff --git a/src/worker.py b/src/worker.py index e87b24e..7e6bb17 100644 --- a/src/worker.py +++ b/src/worker.py @@ -121,22 +121,22 @@ class WorkerSettings: func(task_generate_bdd, max_tries=settings.bdd_max_retries), func(task_generate_user_guide, max_tries=settings.bdd_max_retries), func(task_generate_video, max_tries=settings.video_max_retries), - func( - task_generate_manual_bug_report, - max_tries=settings.manual_report_max_retries, - timeout=settings.manual_report_timeout_seconds, - ), - func(task_report_scenario_to_provider, max_tries=settings.scenario_report_max_retries), + # func( + # task_generate_manual_bug_report, + # max_tries=settings.manual_report_max_retries, + # timeout=settings.manual_report_timeout_seconds, + # ), + # func(task_report_scenario_to_provider, max_tries=settings.scenario_report_max_retries), ] cron_jobs = [ - cron( - cron_poll_unlabeled_data, - hour=CRON_HOURS or list(range(0, 24, 1)), - minute=CRON_MINUTES or list(range(0, 60, 1)), - ), - cron( - cron_poll_scenario_reports, - minute=JIRA_REPORT_CRON_MINUTES or list(range(0, 60, 1)) - ) + # cron( + # cron_poll_unlabeled_data, + # hour=CRON_HOURS or list(range(0, 24, 1)), + # minute=CRON_MINUTES or list(range(0, 60, 1)), + # ), + # cron( + # cron_poll_scenario_reports, + # minute=JIRA_REPORT_CRON_MINUTES or list(range(0, 60, 1)) + # ) ] redis_settings = redis_settings From c6ae807806641cbaa9c38e1097a9d56f7498f200 Mon Sep 17 00:00:00 2001 From: mo2hefny Date: Thu, 25 Jun 2026 20:15:40 +0300 Subject: [PATCH 2/7] feat: enhance jira report content --- src/services/reporting/providers/jira.py | 43 ++++++++++++++++++++++-- src/worker.py | 30 ++++++++--------- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/services/reporting/providers/jira.py b/src/services/reporting/providers/jira.py index 90cae7d..ad573ff 100644 --- a/src/services/reporting/providers/jira.py +++ b/src/services/reporting/providers/jira.py @@ -4,6 +4,7 @@ auth header shape) live here. """ +import re import uuid from urllib.parse import quote @@ -21,8 +22,9 @@ async def create_issue(self, context: dict) -> CreatedIssue: "fields": { "project": {"id": context["reportingConfig"]["project"]["id"]}, "issuetype": {"id": context["reportingConfig"]["issueType"]["id"]}, - "summary": context["report"]["title"], + "summary": self._jira_summary(context["report"]["title"]), "description": self._adf_description(context["structuredDescription"]), + "labels": self._labels(context), } } access = context["access"] @@ -102,11 +104,48 @@ def _adf_description(structured_description: dict) -> dict: ], } + @staticmethod + def _labels(context: dict) -> list[str]: + labels = ["coverit"] + application_name = context.get("applicationName") or context.get("application_name") + application_slug = JiraProvider._label_slug(application_name) + if application_slug: + labels.append(f"coverit-app-{application_slug}"[:255]) + return labels + + @staticmethod + def _jira_summary(value: object) -> str: + if not isinstance(value, str): + return "Untitled report" + first_line = value.strip().splitlines()[0] if value.strip() else "" + summary = re.sub(r"\s+", " ", first_line).strip() + return summary[:255] or "Untitled report" + + @staticmethod + def _label_slug(value: object) -> str: + if not isinstance(value, str): + return "" + slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-") + return re.sub(r"-{2,}", "-", slug) + @staticmethod def _adf_paragraph(text: str) -> dict: + content = [] + lines = (text or " ").splitlines() + if not lines: + lines = [" "] + for index, line in enumerate(lines): + if index > 0: + content.append({"type": "hardBreak"}) + if line: + content.append({"type": "text", "text": line}) + elif index == 0 and len(lines) == 1: + content.append({"type": "text", "text": " "}) + if not content or all(item.get("type") == "hardBreak" for item in content): + content.append({"type": "text", "text": " "}) return { "type": "paragraph", - "content": [{"type": "text", "text": text or " "}], + "content": content, } @staticmethod diff --git a/src/worker.py b/src/worker.py index 7e6bb17..e87b24e 100644 --- a/src/worker.py +++ b/src/worker.py @@ -121,22 +121,22 @@ class WorkerSettings: func(task_generate_bdd, max_tries=settings.bdd_max_retries), func(task_generate_user_guide, max_tries=settings.bdd_max_retries), func(task_generate_video, max_tries=settings.video_max_retries), - # func( - # task_generate_manual_bug_report, - # max_tries=settings.manual_report_max_retries, - # timeout=settings.manual_report_timeout_seconds, - # ), - # func(task_report_scenario_to_provider, max_tries=settings.scenario_report_max_retries), + func( + task_generate_manual_bug_report, + max_tries=settings.manual_report_max_retries, + timeout=settings.manual_report_timeout_seconds, + ), + func(task_report_scenario_to_provider, max_tries=settings.scenario_report_max_retries), ] cron_jobs = [ - # cron( - # cron_poll_unlabeled_data, - # hour=CRON_HOURS or list(range(0, 24, 1)), - # minute=CRON_MINUTES or list(range(0, 60, 1)), - # ), - # cron( - # cron_poll_scenario_reports, - # minute=JIRA_REPORT_CRON_MINUTES or list(range(0, 60, 1)) - # ) + cron( + cron_poll_unlabeled_data, + hour=CRON_HOURS or list(range(0, 24, 1)), + minute=CRON_MINUTES or list(range(0, 60, 1)), + ), + cron( + cron_poll_scenario_reports, + minute=JIRA_REPORT_CRON_MINUTES or list(range(0, 60, 1)) + ) ] redis_settings = redis_settings From 86922b373e4ac20671b87d2e00fb8524ea530803 Mon Sep 17 00:00:00 2001 From: mo2hefny Date: Thu, 25 Jun 2026 20:17:08 +0300 Subject: [PATCH 3/7] test: update tests --- tests/test_jira_provider.py | 73 ++++++++++++++++++++++++++++++++++++- tests/test_manual_bug.py | 1 + 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/tests/test_jira_provider.py b/tests/test_jira_provider.py index 01072fd..49a0c33 100644 --- a/tests/test_jira_provider.py +++ b/tests/test_jira_provider.py @@ -1,9 +1,69 @@ +import asyncio import unittest +from unittest.mock import AsyncMock, patch from src.services.reporting.providers.jira import JiraProvider class JiraProviderTests(unittest.TestCase): + def test_labels_include_coverit_and_safe_application_slug(self): + self.assertEqual( + JiraProvider._labels({"applicationName": "My Shop!"}), + ["coverit", "coverit-app-my-shop"], + ) + + def test_labels_omit_application_label_when_name_is_missing(self): + self.assertEqual(JiraProvider._labels({}), ["coverit"]) + + def test_create_issue_payload_includes_labels(self): + context = { + "access": { + "tokenType": "Bearer", + "accessToken": "access-token", + "cloudId": "cloud-1", + "siteUrl": "https://site.atlassian.net", + }, + "reportingConfig": { + "project": {"id": "10000"}, + "issueType": {"id": "10001"}, + }, + "report": {"title": "Checkout failed"}, + "structuredDescription": {"summary": "Checkout failed", "blocks": []}, + "applicationName": "My Shop!", + } + + with patch("src.services.reporting.providers.jira.json_request", new_callable=AsyncMock) as json_request: + json_request.return_value = (201, {"key": "COV-1", "id": "issue-1"}) + + asyncio.run(JiraProvider().create_issue(context)) + + payload = json_request.await_args.args[2] + self.assertEqual(payload["fields"]["labels"], ["coverit", "coverit-app-my-shop"]) + + def test_create_issue_payload_removes_newlines_from_summary(self): + context = { + "access": { + "tokenType": "Bearer", + "accessToken": "access-token", + "cloudId": "cloud-1", + "siteUrl": "https://site.atlassian.net", + }, + "reportingConfig": { + "project": {"id": "10000"}, + "issueType": {"id": "10001"}, + }, + "report": {"title": "Checkout failed\nButton never submits"}, + "structuredDescription": {"summary": "Checkout failed\nButton never submits", "blocks": []}, + } + + with patch("src.services.reporting.providers.jira.json_request", new_callable=AsyncMock) as json_request: + json_request.return_value = (201, {"key": "COV-1", "id": "issue-1"}) + + asyncio.run(JiraProvider().create_issue(context)) + + payload = json_request.await_args.args[2] + self.assertEqual(payload["fields"]["summary"], "Checkout failed") + def test_adf_description_preserves_frontend_description_text(self): description = "\n".join( [ @@ -31,7 +91,18 @@ def test_adf_description_preserves_frontend_description_text(self): adf["content"][0], { "type": "paragraph", - "content": [{"type": "text", "text": description}], + "content": [ + {"type": "text", "text": "## Summary"}, + {"type": "hardBreak"}, + {"type": "text", "text": "Checkout failed during regression."}, + {"type": "hardBreak"}, + {"type": "hardBreak"}, + {"type": "text", "text": "## Result counts"}, + {"type": "hardBreak"}, + {"type": "text", "text": "- Passed checks: 4"}, + {"type": "hardBreak"}, + {"type": "text", "text": "- Failed checks: 1"}, + ], }, ) self.assertNotIn("user@example.com", str(adf)) diff --git a/tests/test_manual_bug.py b/tests/test_manual_bug.py index 9b9d21d..58db4ce 100644 --- a/tests/test_manual_bug.py +++ b/tests/test_manual_bug.py @@ -45,6 +45,7 @@ async def test_generates_video_and_uploads_to_provider(self): return_value=VideoGenerationResult( status="success", session_id="session-1", + graph_id="graph-1", artifact_path=str(artifact_path), duration_seconds=1.0, resolution="1280x720", From 01aba9dcf99b10c48045b78174cc29fe24c4bfd1 Mon Sep 17 00:00:00 2001 From: mo2hefny Date: Thu, 25 Jun 2026 20:20:41 +0300 Subject: [PATCH 4/7] chore: update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index d6f4da2..4ab62a3 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,5 @@ __marimo__/ .env.*.local .env.* !.env.example + +logs/* \ No newline at end of file From d37d5347e06318e37f1e87e61fdea39e3cb763e8 Mon Sep 17 00:00:00 2001 From: marwan2232004 <118024824+marwan2232004@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:27:48 +0300 Subject: [PATCH 5/7] fix: user edits bdd --- src/models/bdd.py | 2 +- src/services/bdd/editor_steps.py | 45 ++-- src/services/bdd/generate_mapping.py | 379 ++------------------------- src/services/bdd/regression.py | 50 ++-- src/tasks/bdd.py | 3 +- tests/test_bdd.py | 73 ++++++ 6 files changed, 142 insertions(+), 410 deletions(-) diff --git a/src/models/bdd.py b/src/models/bdd.py index 1debb03..01e0f8e 100644 --- a/src/models/bdd.py +++ b/src/models/bdd.py @@ -260,4 +260,4 @@ class CompiledBdd: transitions: dict[str, dict] assertions: dict[str, dict] action_hooks: dict[str, dict] - design_class: dict[str, Any] | None = None + design_classes: dict[str, dict] | None = None diff --git a/src/services/bdd/editor_steps.py b/src/services/bdd/editor_steps.py index eb33ceb..9a08783 100644 --- a/src/services/bdd/editor_steps.py +++ b/src/services/bdd/editor_steps.py @@ -2,30 +2,22 @@ from typing import Literal from src.models.bdd import ( - FLOW_TO_STEP_TYPE, FlowEditorDraftStep, FlowEditorPositionEdge, FlowEditorStepKind, ResolvedFlow, ResolvedState, ResolvedTransition, - StepType, ) -DESIGN_CLASS_ID = "scenarioData" HookPhraseTiming = Literal["before", "after"] HookMappingTiming = Literal["pre", "post"] - -def editor_step_sort_key(step: FlowEditorDraftStep) -> tuple[str, int, int, str]: - edge_rank = 0 if step.position.edge == FlowEditorPositionEdge.BEFORE else 1 - return (step.position.transitionId, edge_rank, step.order, step.id) - - -def sorted_editor_steps( - steps: list[FlowEditorDraftStep], -) -> list[FlowEditorDraftStep]: - return sorted(steps, key=editor_step_sort_key) +EDITOR_STEP_NAME_PREFIXES = { + FlowEditorStepKind.ASSERTION: "ASSERTION", + FlowEditorStepKind.ACTION_HOOK: "HOOK", + FlowEditorStepKind.DESIGN_CLASS: "DESIGN_CLASS", +} def transition_by_input_id(flow: ResolvedFlow) -> dict[str, ResolvedTransition]: @@ -36,13 +28,30 @@ def editor_steps_by_transition( flow: ResolvedFlow, ) -> dict[str, dict[FlowEditorPositionEdge, list[FlowEditorDraftStep]]]: grouped: dict[str, dict[FlowEditorPositionEdge, list[FlowEditorDraftStep]]] = ( - defaultdict(lambda: {FlowEditorPositionEdge.BEFORE: [], FlowEditorPositionEdge.AFTER: []}) + defaultdict( + lambda: { + FlowEditorPositionEdge.BEFORE: [], + FlowEditorPositionEdge.AFTER: [], + } + ) ) - for step in sorted_editor_steps(flow.editor_steps): + for step in flow.editor_steps: grouped[step.position.transitionId][step.position.edge].append(step) return grouped +def generated_editor_step_names(flows: list[ResolvedFlow]) -> dict[int, str]: + counters: dict[FlowEditorStepKind, int] = defaultdict(int) + names: dict[int, str] = {} + for flow in flows: + for step in flow.editor_steps: + counters[step.kind] += 1 + names[id(step)] = ( + f"{EDITOR_STEP_NAME_PREFIXES[step.kind]}_{counters[step.kind]}" + ) + return names + + def hook_phrase_timing(edge: FlowEditorPositionEdge) -> HookPhraseTiming: return "before" if edge == FlowEditorPositionEdge.BEFORE else "after" @@ -58,9 +67,3 @@ def target_state_for_editor_step( if step.position.edge == FlowEditorPositionEdge.BEFORE: return transition.from_state return transition.to_state - - -def executable_step_type(step: FlowEditorDraftStep) -> StepType: - if step.kind == FlowEditorStepKind.DESIGN_CLASS: - return StepType.ACTION_HOOK - return FLOW_TO_STEP_TYPE[step.kind] diff --git a/src/services/bdd/generate_mapping.py b/src/services/bdd/generate_mapping.py index 56da951..2463c67 100644 --- a/src/services/bdd/generate_mapping.py +++ b/src/services/bdd/generate_mapping.py @@ -1,39 +1,21 @@ -from copy import deepcopy -import re from typing import Any from src.utils.helpers import slug, pascal from collections import defaultdict from src.models.bdd import ( BddTransitionAction, - FlowEditorDraftStep, - FlowEditorStepKind, ResolvedFlow, ResolvedState, ResolvedTransition, SemanticAssertion, + FlowEditorStepKind, StepType, ) from src.services.bdd.editor_steps import ( - DESIGN_CLASS_ID, - hook_mapping_timing, - sorted_editor_steps, target_state_for_editor_step, transition_by_input_id, ) -ELEMENT_EXTRACT_SOURCES = { - "text", - "innerText", - "html", - "value", - "checked", - "visible", - "count", - "list", -} - - def _class_name(identifier: str, prefix: str, suffix: str) -> str: stem = identifier.removeprefix(f"{prefix}_") return f"{pascal(stem)}{suffix}" @@ -189,14 +171,15 @@ def generate_user_edit_mappings( flows: list[ResolvedFlow], state_ids: dict[str, str], transition_ids: dict[str, str], + editor_step_names: dict[int, str], ) -> tuple[dict[str, dict], dict[str, dict], dict[str, Any]]: assertions: dict[str, dict] = {} action_hooks: dict[str, dict] = {} - design_class = default_design_class_mapping() + design_classes: dict[str, dict] = {} for flow in flows: transitions_by_input_id = transition_by_input_id(flow) - for step in sorted_editor_steps(flow.editor_steps): + for step in flow.editor_steps: transition = transitions_by_input_id.get(step.position.transitionId) if transition is None: raise ValueError( @@ -206,345 +189,31 @@ def generate_user_edit_mappings( transition_id = transition_ids[transition.db_id] state_id = state_ids[target_state_for_editor_step(step, transition).db_id] - + step_name = editor_step_names[id(step)] if step.kind == FlowEditorStepKind.ASSERTION: - _add_user_assertion_mapping( - assertions, - design_class, - step, - transition_id, - state_id, - state_ids, - ) + assertions[step_name] = { + **step.model_dump(), + "type": StepType.ASSERTION.value, + "stateId": state_id, + "transitionId": transition_id, + } continue if step.kind == FlowEditorStepKind.ACTION_HOOK: - _add_action_hook_mapping( - action_hooks, - step, - transition_id, - state_id, - _normalise_definition(step.definition, state_id, state_ids), - ) + action_hooks[step_name] = { + **step.model_dump(), + "type": StepType.ACTION_HOOK.value, + "transitionId": transition_id, + } continue if step.kind == FlowEditorStepKind.DESIGN_CLASS: - operation = _design_operation_from_step(step, state_id, design_class) - _ensure_unique(design_class["operations"], step.id, "design operation") - design_class["operations"][step.id] = operation - _add_action_hook_mapping( - action_hooks, - step, - transition_id, - state_id, - {"type": "design-operation", "operationId": step.id}, - ) - - return assertions, action_hooks, design_class - - -def default_design_class_mapping() -> dict[str, Any]: - return { - "id": DESIGN_CLASS_ID, - "label": "Scenario Data", - "description": "Single scenario data store for generated regression flows.", - "store": {}, - "extracts": {}, - "expressions": {}, - "functions": {}, - "assertionFunctions": {}, - "operations": {}, - "overwritable": True, - } - - -def _add_user_assertion_mapping( - assertions: dict[str, dict], - design_class: dict[str, Any], - step: FlowEditorDraftStep, - transition_id: str, - state_id: str, - state_ids: dict[str, str], -) -> None: - _ensure_unique(assertions, step.id, "assertion") - definition = deepcopy(step.definition or {}) - - if definition.get("type") == "function": - function_id = _function_id(definition, step) - assertion_function = { - "description": step.label or f"User assertion {step.id}", - } - if definition.get("code"): - assertion_function["code"] = deepcopy(definition["code"]) - if definition.get("severity"): - assertion_function["severity"] = definition["severity"] - _put_unique( - design_class["assertionFunctions"], - function_id, - assertion_function, - "assertion function", - ) - - mapped_definition = { - "type": "user-assertion", - "functionId": function_id, - } - if definition.get("args") is not None: - mapped_definition["args"] = deepcopy(definition["args"]) - else: - mapped_definition = _normalise_definition(definition, state_id, state_ids) - - assertions[step.id] = { - "id": step.id, - "type": StepType.ASSERTION.value, - "label": step.label or f"User assertion {step.id}", - "description": step.label or "", - "targetId": state_id, - "contextId": transition_id, - "severity": definition.get("severity", "blocking"), - "definition": mapped_definition, - "editorStep": _editor_step_metadata(step), - } - - -def _add_action_hook_mapping( - action_hooks: dict[str, dict], - step: FlowEditorDraftStep, - transition_id: str, - state_id: str, - definition: dict[str, Any], -) -> None: - _ensure_unique(action_hooks, step.id, "action hook") - action_hooks[step.id] = { - "id": step.id, - "type": StepType.ACTION_HOOK.value, - "label": step.label or f"User edit {step.id}", - "description": step.label or "", - "timing": hook_mapping_timing(step.position.edge), - "targetType": "transition", - "targetId": transition_id, - "contextId": transition_id, - "order": step.order, - "enabled": True, - "definition": definition, - "editorStep": _editor_step_metadata(step), - } - - -def _design_operation_from_step( - step: FlowEditorDraftStep, - state_id: str, - design_class: dict[str, Any], -) -> dict[str, Any]: - definition = deepcopy(step.definition or {}) - - if definition.get("type") == "function": - function_id = _function_id(definition, step) - design_function = { - "description": step.label or f"User design function {function_id}", - } - if definition.get("code"): - design_function["code"] = deepcopy(definition["code"]) - _put_unique( - design_class["functions"], - function_id, - design_function, - "design function", - ) - - operation: dict[str, Any] = { - "type": "call-function", - "functionId": function_id, - } - if definition.get("args") is not None: - operation["args"] = _normalise_design_values( - definition["args"], - step, - state_id, - design_class, - "args", - ) - if definition.get("assignTo"): - operation["assignTo"] = definition["assignTo"] - _ensure_store_slot(design_class, definition["assignTo"], step.label) - if step.label: - operation["description"] = step.label - return operation - - operation = _normalise_design_values( - definition, - step, - state_id, - design_class, - "operation", - ) - if step.label and isinstance(operation, dict): - operation.setdefault("description", step.label) - if isinstance(operation, dict): - key = operation.get("key") - if isinstance(key, str) and key: - _ensure_store_slot(design_class, key, step.label) - return ( - operation - if isinstance(operation, dict) - else {"type": "set", "value": operation} - ) - - -def _normalise_definition( - definition: dict[str, Any], - state_id: str, - state_ids: dict[str, str], -) -> dict[str, Any]: - normalised = _replace_state_refs(deepcopy(definition or {}), state_ids) - _inject_state_id(normalised, state_id) - return normalised - - -def _replace_state_refs(value: Any, state_ids: dict[str, str]) -> Any: - if isinstance(value, list): - return [_replace_state_refs(item, state_ids) for item in value] - if not isinstance(value, dict): - return value - replaced: dict[str, Any] = {} - for key, item in value.items(): - if key == "stateId" and isinstance(item, str): - replaced[key] = state_ids.get(item, item) - else: - replaced[key] = _replace_state_refs(item, state_ids) - return replaced - - -def _inject_state_id(value: Any, state_id: str) -> None: - if isinstance(value, list): - for item in value: - _inject_state_id(item, state_id) - return - if not isinstance(value, dict): - return - - if value.get("type") in {"element", "element-interaction"} and not value.get( - "stateId" - ): - value["stateId"] = state_id - - for item in value.values(): - _inject_state_id(item, state_id) - - -def _normalise_design_values( - value: Any, - step: FlowEditorDraftStep, - state_id: str, - design_class: dict[str, Any], - path: str, -) -> Any: - if isinstance(value, list): - return [ - _normalise_design_values( - item, step, state_id, design_class, f"{path}_{index}" - ) - for index, item in enumerate(value) - ] - if not isinstance(value, dict): - return value - - if value.get("source") == "element": - return _element_value_as_extract(value, step, state_id, design_class, path) - - return { - key: _normalise_design_values( - item, - step, - state_id, - design_class, - f"{path}_{key}", - ) - for key, item in value.items() - } - - -def _element_value_as_extract( - value: dict[str, Any], - step: FlowEditorDraftStep, - state_id: str, - design_class: dict[str, Any], - path: str, -) -> dict[str, str]: - selector = _first_text( - value.get("selector"), - value.get("locatorKey"), - value.get("cssSelector"), - step.element.selector if step.element else None, - ) - if not selector: - raise ValueError( - f"Design class editor step {step.id} is missing an element selector" - ) - - extract_id = _safe_identifier(f"{step.id}_{path}") - source_hint = _first_text(value.get("attribute"), value.get("token"), "text") - extract: dict[str, Any] = { - "stateId": state_id, - "locator": {"cssSelector": selector}, - "source": ( - source_hint if source_hint in ELEMENT_EXTRACT_SOURCES else "attribute" - ), - "description": step.label or f"Extract for {step.id}", - } - if extract["source"] == "attribute": - extract["attributeName"] = source_hint - - _put_unique(design_class["extracts"], extract_id, extract, "design extract") - return {"from": extract_id} - - -def _function_id(definition: dict[str, Any], step: FlowEditorDraftStep) -> str: - return _first_text(definition.get("functionId"), step.id) or step.id - - -def _ensure_store_slot( - design_class: dict[str, Any], - key: str, - label: str, -) -> None: - design_class["store"].setdefault( - key, - { - "reset": "scenario", - "description": label or f"User-edited value {key}", - }, - ) - - -def _editor_step_metadata(step: FlowEditorDraftStep) -> dict[str, Any]: - return step.model_dump(mode="json", exclude_none=True) - - -def _ensure_unique(mapping: dict[str, Any], key: str, label: str) -> None: - if key in mapping: - raise ValueError(f"Duplicate {label} id from editor steps: {key}") - - -def _put_unique( - mapping: dict[str, Any], - key: str, - value: dict[str, Any], - label: str, -) -> None: - existing = mapping.get(key) - if existing is not None and existing != value: - raise ValueError(f"Conflicting {label} definition for id: {key}") - mapping[key] = value - - -def _first_text(*values: Any) -> str: - for value in values: - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - + design_classes[step_name] = { + **step.model_dump(), + "type": StepType.DESIGN_CLASS.value, + "stateId": state_id, + "transitionId": transition_id, + } + continue -def _safe_identifier(value: str) -> str: - safe = re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") - return safe or "editor_value" + return assertions, action_hooks, design_classes diff --git a/src/services/bdd/regression.py b/src/services/bdd/regression.py index 38385a6..3d8aa17 100644 --- a/src/services/bdd/regression.py +++ b/src/services/bdd/regression.py @@ -7,14 +7,16 @@ CompiledFeature, CompiledBdd, FeaturePlan, - FlowEditorStepKind, ResolvedFlow, ResolvedState, ResolvedTransition, ScenarioPlan, + FlowEditorDraftStep, SemanticAssertion, StepPlan, + FLOW_TO_STEP_TYPE, StepType, + FlowEditorStepKind, FlowEditorPositionEdge, ) from src.utils.helpers import title, upper_snake @@ -30,9 +32,8 @@ if TYPE_CHECKING: from src.services.assertions import SemanticAssertionService from src.services.bdd.editor_steps import ( - DESIGN_CLASS_ID, editor_steps_by_transition, - executable_step_type, + generated_editor_step_names, hook_phrase_timing, ) @@ -121,16 +122,17 @@ def _build_feature_plan( transition_ids: dict[str, str], flow_to_index: dict[int, int], semantic_assertions: dict[int, list[SemanticAssertion]], + editor_step_names: dict[int, str], ) -> FeaturePlan: scenarios: list[ScenarioPlan] = [] - def make_edit_step(edit) -> StepPlan: + def make_edit_step(edit: FlowEditorDraftStep) -> StepPlan: metadata = {} - if executable_step_type(edit) == StepType.ACTION_HOOK: + if FLOW_TO_STEP_TYPE[edit.kind] == StepType.ACTION_HOOK: metadata["timing"] = hook_phrase_timing(edit.position.edge) return StepPlan( - type=executable_step_type(edit), - id=edit.id, + type=FLOW_TO_STEP_TYPE[edit.kind], + id=editor_step_names[id(edit)], keyword="And", metadata=metadata, ) @@ -138,15 +140,6 @@ def make_edit_step(edit) -> StepPlan: for flow, scenario_name in zip(flows, _scenario_names(flows)): edits_by_transition = editor_steps_by_transition(flow) steps: list[StepPlan] = [] - if any(edit.kind == FlowEditorStepKind.DESIGN_CLASS for edit in flow.editor_steps): - steps.append( - StepPlan( - type=StepType.DESIGN_CLASS, - id=DESIGN_CLASS_ID, - keyword="Given", - ) - ) - steps.append( StepPlan( type=StepType.STATE, @@ -166,8 +159,7 @@ def make_edit_step(edit) -> StepPlan: ) for edit in transition_edits[FlowEditorPositionEdge.BEFORE]: - if edit.kind == FlowEditorStepKind.ASSERTION: - steps.append(make_edit_step(edit)) + steps.append(make_edit_step(edit)) steps.append( StepPlan( @@ -177,12 +169,8 @@ def make_edit_step(edit) -> StepPlan: ) ) - for edit in transition_edits[FlowEditorPositionEdge.BEFORE]: - if edit.kind != FlowEditorStepKind.ASSERTION: - steps.append(make_edit_step(edit)) - for edit in transition_edits[FlowEditorPositionEdge.AFTER]: - if edit.kind != FlowEditorStepKind.ASSERTION: + if edit.kind == FlowEditorStepKind.ACTION_HOOK: steps.append(make_edit_step(edit)) steps.append( @@ -195,7 +183,7 @@ def make_edit_step(edit) -> StepPlan: ) for edit in transition_edits[FlowEditorPositionEdge.AFTER]: - if edit.kind == FlowEditorStepKind.ASSERTION: + if edit.kind != FlowEditorStepKind.ACTION_HOOK: steps.append(make_edit_step(edit)) for assertion in semantic_assertions.get( @@ -233,6 +221,7 @@ async def compile_bdd( state_ids = _assign_ids(states, "S") transition_ids = _assign_ids(transitions, "T") flow_to_index = {id(flow): index for index, flow in enumerate(flows)} + editor_step_names = generated_editor_step_names(flows) scenario_names = _scenario_names(flows) semantic_assertions = ( @@ -252,22 +241,19 @@ async def compile_bdd( transition_ids, flow_to_index, semantic_assertions, + editor_step_names, ) for name, group in zip(feature_names, flow_groups) ] - user_assertions, action_hooks, design_class = generate_user_edit_mappings( + user_assertions, action_hooks, design_classes = generate_user_edit_mappings( flows, state_ids, transition_ids, + editor_step_names, ) + assertions = generate_assertion_mappings(semantic_assertions, state_ids) - duplicate_assertion_ids = set(assertions).intersection(user_assertions) - if duplicate_assertion_ids: - raise ValueError( - "User editor assertions conflict with generated assertion ids: " - + ", ".join(sorted(duplicate_assertion_ids)) - ) assertions.update(user_assertions) features = [ @@ -290,5 +276,5 @@ async def compile_bdd( ), assertions=assertions, action_hooks=action_hooks, - design_class=design_class, + design_classes=design_classes, ) diff --git a/src/tasks/bdd.py b/src/tasks/bdd.py index 184a3df..33ed27b 100644 --- a/src/tasks/bdd.py +++ b/src/tasks/bdd.py @@ -67,6 +67,7 @@ async def task_generate_bdd(ctx: dict, payload: dict) -> dict: graph_id = payload.get("graph_id") if isinstance(payload, dict) else None try: request = BddGenerationInput.model_validate(payload) + print(request) graph_id = request.graph_id job_try = int(ctx.get("job_try", 1)) flow_ids = list( @@ -184,7 +185,7 @@ async def task_generate_bdd(ctx: dict, payload: dict) -> dict: "transitions": compiled.transitions, "assertions": compiled.assertions, "action_hooks": compiled.action_hooks, - "design_class": compiled.design_class, + "design_classes": compiled.design_classes, "flow_ids": flow_ids, } diff --git a/tests/test_bdd.py b/tests/test_bdd.py index eb9f782..5afb44d 100644 --- a/tests/test_bdd.py +++ b/tests/test_bdd.py @@ -1,3 +1,4 @@ +import asyncio import unittest from pathlib import Path from unittest.mock import AsyncMock, Mock, patch @@ -8,6 +9,10 @@ BddTransitionAction, BddFlowInput, FeaturePlan, + FlowEditorDraftStep, + FlowEditorPosition, + FlowEditorPositionEdge, + FlowEditorStepKind, ResolvedFlow, ResolvedState, ResolvedTransition, @@ -149,6 +154,74 @@ def test_resolution_preserves_input_order_and_graph(self): class BddCompilerTests(unittest.TestCase): + def test_user_editor_steps_use_generated_names_in_bdd_and_mappings(self): + home = state("s1", "home", "Checkout Page") + done = state("s2", "done", "Done Page") + submit = transition("t1", "submit", "Submit Checkout", home, done) + flow = ResolvedFlow( + checkpoint=home, + transitions=[submit], + editor_steps=[ + FlowEditorDraftStep( + id="editor-assertion", + kind=FlowEditorStepKind.ASSERTION, + position=FlowEditorPosition( + transitionId="submit", + edge=FlowEditorPositionEdge.AFTER, + ), + label="Check success message", + ), + FlowEditorDraftStep( + id="editor-hook", + kind=FlowEditorStepKind.ACTION_HOOK, + position=FlowEditorPosition( + transitionId="submit", + edge=FlowEditorPositionEdge.BEFORE, + ), + label="Wait before submit", + ), + FlowEditorDraftStep( + id="editor-design", + kind=FlowEditorStepKind.DESIGN_CLASS, + position=FlowEditorPosition( + transitionId="submit", + edge=FlowEditorPositionEdge.AFTER, + ), + label="Checkout visual rules", + ), + ], + ) + + compiled = asyncio.run(compile_bdd([flow], {})) + feature_text = compiled.features[0].feature_text + + self.assertIn('And I assert "ASSERTION_1"', feature_text) + self.assertIn('And before action I run hook "HOOK_1"', feature_text) + self.assertIn('And I use design class "DESIGN_CLASS_1"', feature_text) + self.assertNotIn("Check success message", feature_text) + self.assertNotIn("Wait before submit", feature_text) + self.assertNotIn("Checkout visual rules", feature_text) + + self.assertIn("ASSERTION_1", compiled.assertions) + self.assertIn("HOOK_1", compiled.action_hooks) + self.assertIn("DESIGN_CLASS_1", compiled.design_classes) + self.assertNotIn("editor-assertion", compiled.assertions) + self.assertNotIn("editor-hook", compiled.action_hooks) + self.assertNotIn("editor-design", compiled.design_classes) + + assertion = compiled.assertions["ASSERTION_1"] + hook = compiled.action_hooks["HOOK_1"] + design = compiled.design_classes["DESIGN_CLASS_1"] + + self.assertEqual(assertion["id"], "ASSERTION_1") + self.assertEqual(assertion["transitionId"], "T_SUBMIT_CHECKOUT") + self.assertEqual(assertion["stateId"], "S_DONE_PAGE") + self.assertEqual(hook["id"], "HOOK_1") + self.assertEqual(hook["transitionId"], "T_SUBMIT_CHECKOUT") + self.assertEqual(design["id"], "DESIGN_CLASS_1") + self.assertEqual(design["transitionId"], "T_SUBMIT_CHECKOUT") + self.assertEqual(design["stateId"], "S_DONE_PAGE") + def test_compiles_descriptive_feature_and_exact_gherkin(self): home = state("s1", "home", "Shopping Home Page") cart = state("s2", "cart", "Shopping Cart Page") From 4815112bbc2d955fc3e88117c5a904c4f1263b02 Mon Sep 17 00:00:00 2001 From: marwan2232004 <118024824+marwan2232004@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:47:02 +0300 Subject: [PATCH 6/7] feat: update state labels --- src/services/labeling/page_analyzer.py | 218 ++++++++++++++++++------- 1 file changed, 155 insertions(+), 63 deletions(-) diff --git a/src/services/labeling/page_analyzer.py b/src/services/labeling/page_analyzer.py index 89a9589..3fb439a 100644 --- a/src/services/labeling/page_analyzer.py +++ b/src/services/labeling/page_analyzer.py @@ -3,11 +3,38 @@ import re from typing import Dict, Optional from urllib.parse import parse_qsl, unquote, urlparse +from collections import Counter from bs4 import BeautifulSoup, Tag DESCRIPTION_LIMIT = 160 -GENERIC_NAMES = {"home", "welcome", "page", "website", "app", "application"} + +GENERIC_NAMES = { + "home", + "welcome", + "page", + "website", + "app", + "application", + "index", + "main", + "default", + "dashboard", + "panel", + "view", + "tab", + "section", + "content", + "area", + "region", + "site", + "portal", + "module", + "html", + "aspx", + "php", +} + IGNORED_QUERY_KEYS = { "utm_source", "utm_medium", @@ -25,6 +52,7 @@ "code", } QUERY_LABELS = {"tab": "tab", "section": "section", "view": "view"} + SOURCE_WEIGHTS = { "url_path": 60, "title": 50, @@ -32,6 +60,7 @@ "og_title": 30, "active_nav": 20, "domain": 10, + "page_text": 5, } @@ -120,44 +149,58 @@ def _meta_content(soup: BeautifulSoup, **attrs: str) -> str | None: return _normalize_text(tag.get("content")) if isinstance(tag, Tag) else None -def _strip_site_suffix(title: str | None, domain: str | None) -> str | None: - """Remove a likely site-name suffix from a document title.""" +def _tokens(value: str) -> set[str]: + return { + token for token in re.findall(r"[a-z0-9]+", value.casefold()) if len(token) > 1 + } + + +def _strip_site_brand(title: str | None, domain: str | None) -> str | None: + """Remove a likely site-name from the prefix OR suffix of a document title.""" title = _normalize_text(title) if not title: return None + domain_tokens = _tokens(domain or "") + org_markers = { + "app", + "company", + "corporation", + "corp", + "inc", + "labs", + "limited", + "llc", + "platform", + "software", + } + for separator in (" | ", " - ", " — ", " :: "): if separator not in title: continue parts = [part.strip() for part in title.split(separator) if part.strip()] if len(parts) < 2: continue - suffix = parts[-1] - suffix_tokens = _tokens(suffix) - domain_tokens = _tokens(domain or "") - organization_markers = { - "app", - "company", - "corporation", - "corp", - "inc", - "labs", - "limited", - "llc", - "platform", - "software", - } + + # Check Suffix (e.g., "User Profile | Acme") + suffix_tokens = _tokens(parts[-1]) if ( - domain_tokens - and domain_tokens.issubset(suffix_tokens) - or suffix_tokens.intersection(organization_markers) - ): + domain_tokens and domain_tokens.issubset(suffix_tokens) + ) or suffix_tokens.intersection(org_markers): return separator.join(parts[:-1]) + + # Check Prefix (e.g., "Acme | User Profile") + prefix_tokens = _tokens(parts[0]) + if ( + domain_tokens and domain_tokens.issubset(prefix_tokens) + ) or prefix_tokens.intersection(org_markers): + return separator.join(parts[1:]) + return title def _extract_active_nav(soup: BeautifulSoup) -> str | None: - """Return readable text from the first explicitly active nav element.""" + """Return readable text from the leaf node of an active nav element.""" selectors = ( '[aria-current="page"]', '[aria-current="true"]', @@ -169,23 +212,76 @@ def _extract_active_nav(soup: BeautifulSoup) -> str | None: for selector in selectors: element = soup.select_one(selector) if element: - text = _normalize_text(element.get_text(" ", strip=True)) + # Target the deepest leaf node to avoid pulling in entire menu text + leaf = element.find( + lambda t: t.name in ("a", "span", "li") and not t.find("a") + ) + target = leaf if leaf else element + text = _normalize_text(target.get_text(" ", strip=True)) if text: return text return None +def _extract_page_text_keywords(soup: BeautifulSoup) -> str | None: + """Local Unsupervised Fallback: Extract top 2-3 keywords from page headings.""" + texts = [] + for tag in soup.find_all(["h1", "h2", "h3"]): + t = tag.get_text(" ", strip=True) + if t: + texts.append(t) + + if not texts: + return None + + # Tokenize, filter stopwords/generic terms, and count frequencies + words = [] + for text in texts: + words.extend( + [ + w.lower() + for w in re.findall(r"[A-Za-z]{3,}", text) + if w.lower() not in GENERIC_NAMES + ] + ) + + if not words: + return None + + # Take the 3 most frequent words on the page + common = [word for word, _ in Counter(words).most_common(3)] + return " ".join(common).title() if common else None + + +def _clean_and_trim(value: str | None, max_words: int = 3) -> str | None: + """Filter out stopwords and enforce a strict word limit (default 3).""" + if not value: + return None + + tokens = re.findall(r"[A-Za-z0-9]+", value) + filtered = [t for t in tokens if t.lower() not in GENERIC_NAMES] + + if not filtered: + return None + + # Keep the last N tokens (most specific for URLs and Titles) + if len(filtered) > max_words: + filtered = filtered[-max_words:] + + return " ".join(filtered).title() + + def _extract_html_signals( soup: BeautifulSoup, domain: str | None ) -> dict[str, str | None]: """Extract cleaned title, heading, metadata, and active-navigation text.""" title_tag = soup.find("title") h1 = soup.find("h1") + + raw_title = title_tag.get_text(" ", strip=True) if title_tag else None + return { - "title": _strip_site_suffix( - title_tag.get_text(" ", strip=True) if title_tag else None, - domain, - ), + "title": _strip_site_brand(raw_title, domain), "h1": _normalize_text(h1.get_text(" ", strip=True)) if h1 else None, "og_title": _meta_content(soup, property="og:title") or _meta_content(soup, name="og:title"), @@ -193,45 +289,45 @@ def _extract_html_signals( or _meta_content(soup, name="og:description"), "meta_description": _meta_content(soup, name="description"), "active_nav": _extract_active_nav(soup), - } - - -def _tokens(value: str) -> set[str]: - return { - token - for token in re.findall(r"[a-z0-9]+", value.casefold()) - if len(token) > 1 + "page_text": _extract_page_text_keywords(soup), # Self-contained ML/TF fallback } def _select_page_name(signals: dict[str, str | None]) -> str | None: - """Select the strongest specific name using source priority and agreement.""" + """Select the strongest specific name using source priority and word limits.""" candidates: list[tuple[int, int, str]] = [] seen: set[str] = set() - populated = [ - value - for source, value in signals.items() - if source in SOURCE_WEIGHTS and value - ] + + # Pre-clean all signals to enforce the 2-3 word limit before scoring + cleaned_signals = { + k: _clean_and_trim(v) if v else None + for k, v in signals.items() + if k in SOURCE_WEIGHTS + } + + populated = [v for v in cleaned_signals.values() if v] for order, (source, weight) in enumerate(SOURCE_WEIGHTS.items()): - value = signals.get(source) + value = cleaned_signals.get(source) if not value or value.casefold() in seen: continue seen.add(value.casefold()) + score = weight value_tokens = _tokens(value) - if value.casefold() in GENERIC_NAMES: + + # Penalize single-word names if they are generic + if len(value_tokens) <= 1 and value.casefold() in GENERIC_NAMES: score -= 20 - if source == "url_path" and " > " in value: - score += 5 + + # Reward agreement with other signals if any( - value_tokens - and value_tokens.intersection(_tokens(other)) + value_tokens.intersection(_tokens(other)) for other in populated if other != value ): score += 10 + candidates.append((score, -order, value)) return max(candidates, default=(0, 0, None))[2] @@ -263,31 +359,27 @@ def _select_page_description( return None -def get_page_info( - url: str, soup: BeautifulSoup -) -> Dict[str, Optional[str]]: - """Return a deterministic human-readable page name and description. +def get_page_info(url: str, soup: BeautifulSoup) -> Dict[str, Optional[str]]: + """Return a deterministic human-readable page name (2-3 words) and description. Args: - url: Page URL. Semantic path segments, selected query parameters, and - fragments are humanized; numeric IDs, UUIDs, files, tokens, - tracking parameters, pagination, and sorting values are ignored. - soup: Parsed page snapshot used for title, ``h1``, Open Graph, - description metadata, and active-navigation signals. + url: Page URL. Semantic path segments are humanized; IDs, UUIDs, files, + tokens, and tracking parameters are ignored. + soup: Parsed page snapshot used for title, headings, Open Graph, + description metadata, and active-navigation signals. Returns: - ``{"name": ..., "description": ...}``. Name selection prioritizes a - clean URL path, stripped title, ``h1``, Open Graph title, active - navigation, then domain. Agreement and specificity adjust those fixed - weights. Description selection prioritizes Open Graph description, - meta description, then non-duplicative heading or navigation context, - normalized to one sentence and at most 160 characters. + ``{"name": ..., "description": ...}``. Name selection strictly enforces + a 2-3 word limit by filtering generic stopwords and trimming. Prioritizes + a clean URL path, stripped title, ``h1``, Open Graph title, active + navigation, domain, and finally local page text frequency. """ url_signals = _extract_url_signals(url) signals = { **url_signals, **_extract_html_signals(soup, url_signals["domain"]), } + name = _select_page_name(signals) return { "name": name, From 79650ab69d782ef86cd26eaa61c36768c41e3643 Mon Sep 17 00:00:00 2001 From: marwan2232004 <118024824+marwan2232004@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:08:34 +0300 Subject: [PATCH 7/7] fix: video generation --- src/core/config.py | 2 +- src/services/video/renderer.py | 696 +++++++++++++++++++++++++++++++-- src/worker.py | 52 +-- 3 files changed, 661 insertions(+), 89 deletions(-) diff --git a/src/core/config.py b/src/core/config.py index 4bc5268..97450c6 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -34,7 +34,7 @@ class Settings(BaseSettings): video_default_width: int = 1280 video_default_height: int = 720 video_default_fps: int = 30 - video_action_speed: float = 1.0 + video_action_speed: float = 0.5 video_random_seed: int = 42 video_window_scale: float = Field(default=0.86, gt=0.0, le=1.0) video_focus_zoom: float = Field(default=1.4, ge=1.0, le=2.0) diff --git a/src/services/video/renderer.py b/src/services/video/renderer.py index e3beab6..f2cb732 100644 --- a/src/services/video/renderer.py +++ b/src/services/video/renderer.py @@ -1,6 +1,9 @@ from dataclasses import dataclass, replace from io import BytesIO from pathlib import Path +import re +from time import monotonic +from urllib.parse import urljoin from typing import Iterable from playwright.async_api import TimeoutError as PlaywrightTimeoutError @@ -28,8 +31,10 @@ class CapturedScene: image: object target: Rect document_target: Rect + click_point: Point scroll: Point cursor_kind: CursorKind + locator: object | None = None @dataclass(frozen=True) @@ -135,9 +140,28 @@ async def render( already_focused = False for shot_index, shot in enumerate(timeline.shots): - scene = pending_scene or await self._capture_scene(page, shot) + try: + scene = pending_scene or await self._capture_scene_when_ready( + page, + shot, + ) + except ValueError as exc: + if await self._perform_missing_selector_action( + page, + shot, + exc, + ): + pending_scene = None + already_focused = False + continue + raise pending_scene = None - target_center = scene.target.center + if scene is None: + await self._perform_nonvisual_action(page, shot) + already_focused = False + continue + + target_center = scene.click_point if already_focused: already_focused = False @@ -175,6 +199,7 @@ async def render( await self._perform_typing_action( page, shot, + scene.locator, scene.target, target_center, scene.cursor_kind, @@ -183,7 +208,12 @@ async def render( ) release_image = await self._capture_page(page) else: - await self._perform_non_typing_action(page, shot) + await self._perform_non_typing_action( + page, + shot, + scene.locator, + target_center, + ) release_image = await self._capture_page(page) sink.write( self._press_frames( @@ -206,9 +236,13 @@ async def render( next_scene = None if shot_index + 1 < len(timeline.shots): - next_scene = await self._capture_scene( + next_shot = timeline.shots[shot_index + 1] + next_scene = await self._capture_next_scene_after_action( page, - timeline.shots[shot_index + 1], + shot, + scene, + target_center, + next_shot, ) if next_scene and self._should_stick_to_next( @@ -220,11 +254,11 @@ async def render( release_scene, next_scene, target_center, - next_scene.target.center, + next_scene.click_point, ) ) pending_scene = next_scene - previous_cursor = next_scene.target.center + previous_cursor = next_scene.click_point previous_cursor_kind = next_scene.cursor_kind already_focused = True else: @@ -265,25 +299,94 @@ async def render( # ------------------------------------------------------------------ # Scene capture helpers # ------------------------------------------------------------------ - async def _capture_scene( + async def _try_capture_scene( self, page, shot: VideoShot, - ) -> CapturedScene: - locator = page.locator(shot.selector).first - if await locator.count() == 0: - raise ValueError( - f"Selector {shot.selector!r} did not match for transition " - f"{shot.transition_id}" + ) -> CapturedScene | None: + return await self._capture_scene_when_ready(page, shot, timeout_ms=0) + + async def _capture_scene_when_ready( + self, + page, + shot: VideoShot, + timeout_ms: int = 7000, + ) -> CapturedScene | None: + deadline = monotonic() + timeout_ms / 1000 + last_error: ValueError | None = None + + while True: + try: + return await self._capture_scene(page, shot) + except ValueError as exc: + message = str(exc) + if "has no visible bounding box" in message: + if await self._is_nonvisual_action_target(page, shot): + return None + last_error = exc + elif "did not match" in message: + last_error = exc + else: + raise + + if monotonic() >= deadline: + raise last_error or ValueError( + f"Selector {shot.selector!r} was not ready for transition " + f"{shot.transition_id}" + ) + + await self._wait_for_timeout(page, 250) + + async def _capture_next_scene_after_action( + self, + page, + shot: VideoShot, + scene: CapturedScene, + click_point: Point, + next_shot: VideoShot, + ) -> CapturedScene | None: + try: + return await self._capture_scene_when_ready(page, next_shot) + except ValueError: + if shot.has_typing: + await self._press_enter_to_materialize_result(page, next_shot) + try: + return await self._capture_scene_when_ready( + page, + next_shot, + timeout_ms=3500, + ) + except ValueError: + return None + + if shot.has_typing or scene.locator is None: + raise + + action_type = shot.action_type.lower() + if action_type not in {"", "click", "press", "tap"}: + raise + + await self._click_locator_direct( + page, + scene.locator, + scene.target, + click_point, ) + await self._wait_for_page_stability(page) + try: + return await self._capture_scene_when_ready(page, next_shot) + except ValueError: + return None - await locator.scroll_into_view_if_needed(timeout=5000) + async def _capture_scene( + self, + page, + shot: VideoShot, + ) -> CapturedScene: + locator, box = await self._visible_locator_for(page, shot) scroll = await self._page_scroll(page) - box = await locator.bounding_box() - if box is None: - raise ValueError(f"Selector {shot.selector!r} has no visible bounding box") - target = self._box_from_js(box, shot.selector) + click_point = self._visible_click_point(target) document_target = Rect( target.x + scroll.x, target.y + scroll.y, @@ -294,10 +397,170 @@ async def _capture_scene( image=await self._capture_page(page), target=target, document_target=document_target, + click_point=click_point, scroll=scroll, cursor_kind=await self._cursor_kind_for(locator, shot), + locator=locator, ) + async def _visible_locator_for(self, page, shot: VideoShot): + any_match = False + for locator in self._locator_candidates(page, shot.selector): + count = await locator.count() + if count == 0: + continue + any_match = True + + fallback = None + for index in range(count): + nth = getattr(locator, "nth", None) + if nth is None: + if index > 0: + break + candidate = locator.first + else: + candidate = nth(index) + await self._scroll_locator_to_center(page, candidate) + box = await candidate.bounding_box() + if box is not None: + if fallback is None: + fallback = (candidate, box) + try: + target = self._box_from_js(box, shot.selector) + except ValueError: + continue + if await self._candidate_receives_pointer( + candidate, + self._visible_click_point(target), + ): + return candidate, box + + if fallback is not None: + return fallback + + if not any_match: + raise ValueError( + f"Selector {shot.selector!r} did not match for transition " + f"{shot.transition_id}" + ) + + raise ValueError(f"Selector {shot.selector!r} has no visible bounding box") + + def _locator_candidates(self, page, selector: str): + seen: set[str] = set() + + def add_css(css: str): + if css and css not in seen: + seen.add(css) + yield page.locator(css) + + yield from add_css(selector) + + normalized = self._normalize_selector(selector) + if normalized != selector: + yield from add_css(normalized) + + href = self._href_from_selector(selector) + if not href: + return + + href_variants = self._href_variants(href) + for href_value in href_variants: + escaped = self._css_string(href_value) + yield from add_css(f'a[href="{escaped}"]') + yield from add_css(f'a[href$="{escaped}"]') + yield from add_css(f'a[href*="{escaped}"]') + yield from add_css(f'a[href$="{escaped}" i]') + yield from add_css(f'a[href*="{escaped}" i]') + + for text in self._link_text_candidates(href): + locator = page.locator("a") + filter_method = getattr(locator, "filter", None) + if filter_method is not None: + try: + yield filter_method(has_text=text) + except TypeError: + pass + + def _normalize_selector(self, selector: str) -> str: + return ( + selector.replace("\\/", "/") + .replace('\\"', '"') + ) + + def _href_from_selector(self, selector: str) -> str | None: + match = re.search(r"""href\s*=\s*(['"])(.*?)\1""", selector) + if not match: + return None + return self._normalize_selector(match.group(2)) + + def _href_variants(self, href: str) -> list[str]: + variants = [] + for value in { + href, + href.rstrip("/"), + href.lower(), + href.lower().rstrip("/"), + }: + if value and value not in variants: + variants.append(value) + return variants + + def _link_text_candidates(self, href: str) -> list[str]: + path = href.split("?", 1)[0].strip("/") + parts = [part for part in path.split("/") if part] + candidates: list[str] = [] + if parts: + candidates.append(parts[-1]) + if len(parts) >= 2: + candidates.append("/".join(parts[-2:])) + return candidates + + def _css_string(self, value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + async def _candidate_receives_pointer(self, locator, point: Point) -> bool: + evaluate = getattr(locator, "evaluate", None) + if evaluate is None: + return True + + try: + receives_pointer = await evaluate( + """(element, point) => { + const hit = document.elementFromPoint(point.x, point.y); + return hit === element || element.contains(hit); + }""", + {"x": point.x, "y": point.y}, + ) + except Exception: + return True + + return bool(receives_pointer) + + async def _scroll_locator_to_center(self, page, locator) -> None: + evaluate = getattr(locator, "evaluate", None) + if evaluate is not None: + try: + result = await evaluate( + """element => { + element.scrollIntoView({ + block: "center", + inline: "center" + }); + }""" + ) + await self._wait_for_timeout(page, 100) + if result is None: + return + except Exception: + pass + + try: + await locator.scroll_into_view_if_needed(timeout=5000) + except PlaywrightTimeoutError: + pass + await self._wait_for_timeout(page, 50) + async def _capture_page(self, page): from PIL import Image @@ -324,21 +587,34 @@ async def _page_scroll(self, page) -> Point: return Point(0.0, 0.0) return Point(float(value.get("x") or 0.0), float(value.get("y") or 0.0)) + async def _wait_for_timeout(self, page, milliseconds: int) -> None: + wait_for_timeout = getattr(page, "wait_for_timeout", None) + if wait_for_timeout is None: + return + try: + await wait_for_timeout(milliseconds) + except Exception: + pass + async def _cursor_kind_for(self, locator, shot: VideoShot) -> CursorKind: if shot.has_typing: return "text" + return self._cursor_kind_from_metadata(await self._element_metadata(locator)) + + async def _element_metadata(self, locator) -> dict: evaluate = getattr(locator, "evaluate", None) if evaluate is None: - return "default" - + return {} try: - metadata = await evaluate( + value = await evaluate( """element => { const style = window.getComputedStyle(element); return { tagName: element.tagName, type: element.getAttribute("type") || "", + name: element.getAttribute("name") || "", + id: element.getAttribute("id") || "", role: element.getAttribute("role") || "", href: element.getAttribute("href") || "", cursor: style.cursor || "", @@ -347,9 +623,43 @@ async def _cursor_kind_for(self, locator, shot: VideoShot) -> CursorKind: }""" ) except Exception: - return "default" + return {} + + return value if isinstance(value, dict) else {} + + async def _is_nonvisual_action_target(self, page, shot: VideoShot) -> bool: + locator = page.locator(shot.selector) + try: + count = await locator.count() + except Exception: + return False + + for index in range(min(count, 5)): + metadata = await self._element_metadata(locator.nth(index)) + if self._metadata_is_nonvisual_target(metadata): + return True + return False - return self._cursor_kind_from_metadata(metadata) + def _metadata_is_nonvisual_target(self, metadata: dict) -> bool: + tag = str(metadata.get("tagName") or metadata.get("tag") or "").lower() + input_type = str(metadata.get("type") or "").lower() + name = str(metadata.get("name") or "").lower() + element_id = str(metadata.get("id") or "").lower() + + if tag == "input" and input_type == "hidden": + return True + + technical_names = { + "authenticity_token", + "csrf", + "csrf_token", + "webauthn-support", + } + return tag == "input" and ( + name in technical_names + or element_id in technical_names + or name.startswith("_") + ) def _cursor_kind_from_metadata(self, metadata) -> CursorKind: if not isinstance(metadata, dict): @@ -386,18 +696,52 @@ def _cursor_kind_from_metadata(self, metadata) -> CursorKind: return "default" + def _visible_click_point(self, target: Rect) -> Point: + viewport_right = float(self.config.width) + viewport_bottom = float(self.config.height) + visible_left = max(0.0, target.x) + visible_top = max(0.0, target.y) + visible_right = min(viewport_right, target.x + target.width) + visible_bottom = min(viewport_bottom, target.y + target.height) + + if visible_right > visible_left and visible_bottom > visible_top: + return Point( + (visible_left + visible_right) / 2.0, + (visible_top + visible_bottom) / 2.0, + ) + + return Point( + min(max(target.center.x, 0.0), max(0.0, viewport_right - 1.0)), + min(max(target.center.y, 0.0), max(0.0, viewport_bottom - 1.0)), + ) + + def _cursor_kind_for_position( + self, + cursor_kind: CursorKind, + cursor: Point, + target: Rect, + ) -> CursorKind: + if cursor_kind != "hand": + return cursor_kind + + inside_x = target.x <= cursor.x <= target.x + target.width + inside_y = target.y <= cursor.y <= target.y + target.height + return "hand" if inside_x and inside_y else "default" + async def _perform_typing_action( self, page, shot: VideoShot, + locator, target: Rect, cursor: Point, cursor_kind: CursorKind, shot_index: int, ): frames = [] - locator = page.locator(shot.selector).first - await locator.click(timeout=5000) + if locator is None: + locator, _ = await self._visible_locator_for(page, shot) + await self._click_at(page, locator, cursor) try: await locator.fill("", timeout=3000) except Exception: @@ -421,22 +765,273 @@ async def _perform_typing_action( cursor, zoom_progress=1.0, caret_visible=typed_frame.caret_visible, - cursor_kind=cursor_kind, + cursor_kind=self._cursor_kind_for_position( + cursor_kind, + cursor, + target, + ), ) repeats = max(1, self._scaled_frame_count(0.08)) frames.extend([frame.copy() for _ in range(repeats)]) + await self._wait_for_page_stability(page) return frames - async def _perform_non_typing_action(self, page, shot: VideoShot) -> None: - locator = page.locator(shot.selector).first + async def _perform_non_typing_action( + self, + page, + shot: VideoShot, + locator, + click_point: Point, + ) -> None: + if locator is None: + locator, _ = await self._visible_locator_for(page, shot) action_type = shot.action_type.lower() - if action_type in {"select", "option"} and shot.value is not None: - await locator.select_option(shot.value, timeout=5000) - else: - await locator.click(timeout=5000) + metadata = await self._element_metadata(locator) + is_native_select = str(metadata.get("tagName") or "").lower() == "select" + metadata_unavailable = not metadata + + await self._click_at(page, locator, click_point) + + if ( + action_type in {"select", "option"} + and shot.value is not None + and (is_native_select or metadata_unavailable) + ): + try: + await locator.select_option(shot.value, timeout=5000) + except Exception: + if is_native_select: + raise await self._wait_for_page_stability(page) + async def _perform_nonvisual_action(self, page, shot: VideoShot) -> None: + locator = page.locator(shot.selector) + if await locator.count() == 0: + raise ValueError( + f"Selector {shot.selector!r} did not match for transition " + f"{shot.transition_id}" + ) + + action_type = shot.action_type.lower() + target = locator.first + if shot.value is not None and ( + shot.has_typing or action_type in {"select", "option"} + ): + await self._set_nonvisual_value(target, shot.value) + elif action_type in {"click", "press", "tap"}: + await self._click_nonvisual(target) + + await self._wait_for_page_stability(page) + + async def _perform_missing_selector_action( + self, + page, + shot: VideoShot, + exc: ValueError, + ) -> bool: + if "did not match" not in str(exc): + return False + + action_type = shot.action_type.lower() + if action_type not in {"", "click", "press", "tap"}: + return False + + href = self._href_from_selector(shot.selector) + if not href: + return False + + await self._navigate_to_href(page, href) + return True + + async def _press_enter_to_materialize_result( + self, + page, + next_shot: VideoShot, + ) -> None: + if not self._href_from_selector(next_shot.selector): + return + + keyboard = getattr(page, "keyboard", None) + press = getattr(keyboard, "press", None) + if press is None: + return + + try: + await press("Enter") + except Exception: + return + + await self._wait_for_page_stability(page) + + async def _navigate_to_href(self, page, href: str) -> None: + current_url = await self._current_page_url(page) + target_url = urljoin(current_url, href) + await page.goto(target_url, wait_until="load", timeout=30000) + await self._wait_for_page_stability(page) + + async def _current_page_url(self, page) -> str: + url = getattr(page, "url", "") + if isinstance(url, str) and url: + return url + + evaluate = getattr(page, "evaluate", None) + if evaluate is None: + return "" + + try: + value = await evaluate("() => window.location.href") + except Exception: + return "" + + return value if isinstance(value, str) else "" + + async def _set_nonvisual_value(self, locator, value: str) -> None: + evaluate = getattr(locator, "evaluate", None) + if evaluate is None: + return + + try: + await evaluate( + """(element, value) => { + if ("value" in element) { + element.value = value; + } + element.setAttribute("value", value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + }""", + value, + ) + except Exception: + pass + + async def _click_nonvisual(self, locator) -> None: + await self._dispatch_dom_click(locator) + + async def _click_at(self, page, locator, point: Point) -> None: + mouse = getattr(page, "mouse", None) + mouse_click = getattr(mouse, "click", None) + if mouse_click is not None: + mouse_move = getattr(mouse, "move", None) + if mouse_move is not None: + await mouse_move(point.x, point.y) + await mouse_click(point.x, point.y) + return + + await locator.click(timeout=5000) + + async def _click_locator_direct( + self, + page, + locator, + target: Rect, + point: Point, + ) -> None: + click = getattr(locator, "click", None) + position = { + "x": min(max(point.x - target.x, 0.0), max(1.0, target.width)), + "y": min(max(point.y - target.y, 0.0), max(1.0, target.height)), + } + if click is not None: + if await self._try_locator_click(click, position, force=False): + return + if await self._try_locator_click(click, position, force=True): + return + + if await self._dispatch_dom_click(locator): + return + + await self._activate_locator_with_keyboard(page, locator) + + async def _try_locator_click( + self, + click, + position: dict[str, float], + force: bool, + ) -> bool: + kwargs = { + "position": position, + "timeout": 1800, + } + if force: + kwargs["force"] = True + + try: + await click(**kwargs) + return True + except TypeError: + try: + await click(timeout=1800, force=force) + return True + except TypeError: + try: + await click(timeout=1800) + return True + except Exception: + return False + except Exception: + return False + except Exception: + return False + + async def _dispatch_dom_click(self, locator) -> bool: + evaluate = getattr(locator, "evaluate", None) + if evaluate is None: + return False + + try: + return bool( + await evaluate( + """element => { + const options = { + bubbles: true, + cancelable: true, + view: window + }; + element.dispatchEvent(new PointerEvent("pointerdown", options)); + element.dispatchEvent(new MouseEvent("mousedown", options)); + element.dispatchEvent(new PointerEvent("pointerup", options)); + element.dispatchEvent(new MouseEvent("mouseup", options)); + element.dispatchEvent(new MouseEvent("click", options)); + if (typeof element.click === "function") { + element.click(); + } + return true; + }""" + ) + ) + except Exception: + return False + + async def _activate_locator_with_keyboard( + self, + page, + locator, + ) -> None: + focus = getattr(locator, "focus", None) + if focus is not None: + try: + await focus(timeout=1000) + except TypeError: + try: + await focus() + except Exception: + pass + except Exception: + pass + + keyboard = getattr(page, "keyboard", None) + press = getattr(keyboard, "press", None) + if press is None: + return + + try: + await press("Enter") + except Exception: + pass + async def _wait_for_page_stability(self, page) -> None: + await self._wait_for_timeout(page, 150) try: await page.wait_for_load_state("networkidle", timeout=5000) except PlaywrightTimeoutError: @@ -466,7 +1061,11 @@ def _rest_frames( target, cursor, zoom_progress=0.0, - cursor_kind=cursor_kind, + cursor_kind=self._cursor_kind_for_position( + cursor_kind, + cursor, + target, + ), ) for _ in range(count) ] @@ -492,7 +1091,11 @@ def _zoom_frames( target, cursor, zoom_progress=progress, - cursor_kind=cursor_kind, + cursor_kind=self._cursor_kind_for_position( + cursor_kind, + cursor, + target, + ), ) ) return frames @@ -512,6 +1115,11 @@ def _cursor_frames( fraction = (index + 1) / count cursor = curved_cursor_path(start, end, fraction) cursor_kind = end_cursor_kind if fraction > 0.82 else start_cursor_kind + cursor_kind = self._cursor_kind_for_position( + cursor_kind, + cursor, + target, + ) frames.append( self._compose_frame( image, @@ -549,6 +1157,11 @@ def _focus_pan_frames( if fraction > 0.82 else start_scene.cursor_kind ) + cursor_kind = self._cursor_kind_for_position( + cursor_kind, + cursor, + target, + ) frames.append( self._compose_frame( blended, @@ -581,7 +1194,11 @@ def _press_frames( cursor, zoom_progress=1.0, cursor_press=press, - cursor_kind=cursor_kind, + cursor_kind=self._cursor_kind_for_position( + cursor_kind, + cursor, + target, + ), ) ) return frames @@ -600,7 +1217,11 @@ def _hold_frames( target, cursor, zoom_progress=1.0, - cursor_kind=cursor_kind, + cursor_kind=self._cursor_kind_for_position( + cursor_kind, + cursor, + target, + ), ) for _ in range(count) ] @@ -692,6 +1313,7 @@ def _compose_frame( ): from PIL import Image + cursor_kind = self._cursor_kind_for_position(cursor_kind, cursor, target) params = self._window_params transform = window_transform_for_progress(params, target, zoom_progress) diff --git a/src/worker.py b/src/worker.py index 00226c7..71c21b3 100644 --- a/src/worker.py +++ b/src/worker.py @@ -51,56 +51,6 @@ async def startup(ctx: dict) -> None: neo_manager.init() await playwright_manager.start() logger.info("Worker initialized external connections") - payload = { - "graph_id": "38e62f81-0d53-4faa-93a2-94af3c3290de", - "flows": [ - { - "checkpoint_hash": "45e3bf1699fbc67091c51c0fe3293580d128f63b77e67df217c368dd9dd99dad", - "transition_ids": [ - "9e5fd44ed9ba279d7dcc07befc5ca4ee1a6f80f20ecbc256af10d6fea7d23539", - "7c6b718d7f501d88af2833f3f6073e183eeb7deec3ffc6d8f4b9ac1002330e21", - "51e3993c1d8f2240bdc3a19e1a252bfca06c6f9f34f3a552942f7891cf1ee165", - "dd1c32ae7966158c9db7c79ed08c9ea70580d1bdd38da1e67245949d8e53d22c", - "6581f43c6e97734930e8115305644959d1b4546b7bec35bebcbfee9de57b3578", - "f5c1e8a6d4be6bef582660223c28641c12ba9137cbf20040749be03b46454ac7", - "12354539e457d269a5734f088c75fbdc9dd1b893725b765a089e797673a03275" - ], - } - ], - } - guides_payload = { - "graph_id": "4815ce8f-8233-4786-9f43-dee9f48b1af9", - "start_state_hash": "2080d89bad002cd649be78af6e80ab6c479bb820c523084828f28a4fec2ebf50", - "end_state_hash": "65e803f3a41d4af684c2b2f708d649609c6dd061496e5206872a0f6f6ec5f6a9", - } - bdd_payload = { - "graph_id": "5be4d1d6-9fbd-4663-9ebd-6a021d9762d8", - "session_id": "5be4d1d6-9fbd-4663-9ebd-6a021d9762d8", - "flows": [ - { - "checkpoint_hash": "45e3bf1699fbc67091c51c0fe3293580d128f63b77e67df217c368dd9dd99dad", - "transition_ids": [ - "b62a3d6ba6f895e8ebe7faff448a2dfd182c62684f93c26a3f40fd9ba39319c4" - ], - }, - # { - # "checkpoint_hash": "458a29b4f8c2bc3b53808de0ce4f272ba7057adb025251962d44cea575e555a2", - # "transition_ids": [ - # "233a6a6e4aa6cce0103ae2960c10a62ee644189eefaee0d1e728c22389dc416a", - # "e55b5430bfc2a17e9ec3ce9d0256f0a5f28f6a85c60ab54e1beeaa7fa12ef13f" - # ], - # }, - ], - } - # await ctx["redis"].enqueue_job( - # "task_generate_video", payload=payload, _queue_name="docgen:queue" - # ) - # await ctx["redis"].enqueue_job( - # "task_generate_user_guide", payload=guides_payload, _queue_name="docgen:queue" - # ) - # await ctx["redis"].enqueue_job( - # "task_generate_bdd", payload=bdd_payload, _queue_name="docgen:queue" - # ) async def shutdown(ctx: dict) -> None: @@ -124,7 +74,7 @@ class WorkerSettings: task_label_graph, func(task_generate_bdd, max_tries=settings.bdd_max_retries), func(task_generate_user_guide, max_tries=settings.bdd_max_retries), - func(task_generate_video, max_tries=settings.video_max_retries), + func(task_generate_video, max_tries=settings.video_max_retries, timeout=1200), func( task_generate_manual_bug_report, max_tries=settings.manual_report_max_retries,