"""Step definitions for CI workflow validation feature.""" from pathlib import Path import yaml from behave import given, then, when @given('the CI workflow file at "{path}"') def step_given_ci_workflow_file(context, path): """Store the CI workflow file path.""" context.ci_workflow_path = Path(path) @then("the CI workflow file should exist") def step_then_ci_workflow_exists(context): """Verify the CI workflow file exists.""" if not context.ci_workflow_path.exists(): raise AssertionError( f"CI workflow file not found at {context.ci_workflow_path}" ) @when("I parse the CI workflow YAML") def step_when_parse_ci_workflow(context): """Parse the CI workflow YAML file.""" workflow_path = context.ci_workflow_path if not workflow_path.exists(): raise FileNotFoundError(f"CI workflow file not found at {workflow_path}") with workflow_path.open("r") as f: context.ci_workflow = yaml.safe_load(f) @then('the workflow should have a job named "{job_name}"') def step_then_workflow_has_job(context, job_name): """Verify the workflow has a specific job.""" jobs = context.ci_workflow.get("jobs", {}) if job_name not in jobs: raise AssertionError( f"Job '{job_name}' not found in workflow. " f"Available jobs: {list(jobs.keys())}" ) @then('the job "{job_name}" should run "{command}"') def step_then_job_runs_command(context, job_name, command): """Verify a job runs a specific command in one of its steps.""" jobs = context.ci_workflow.get("jobs", {}) job = jobs.get(job_name) if job is None: raise AssertionError(f"Job '{job_name}' not found in workflow") steps = job.get("steps", []) found = False for step in steps: run_cmd = step.get("run", "") if command in run_cmd: found = True break if not found: step_runs = [s.get("run", "(no run)") for s in steps if "run" in s] raise AssertionError( f"Command '{command}' not found in job '{job_name}' steps. " f"Step runs: {step_runs}" ) @then('the workflow env should set "{key}" to "{value}"') def step_then_workflow_env_set(context, key, value): """Verify a workflow-level environment variable is set.""" env = context.ci_workflow.get("env", {}) actual = env.get(key) if actual != value: raise AssertionError(f"Workflow env '{key}' expected '{value}', got '{actual}'") @then('the job "{job_name}" should depend on "{dependency}"') def step_then_job_depends_on(context, job_name, dependency): """Verify a job has a specific dependency.""" jobs = context.ci_workflow.get("jobs", {}) job = jobs.get(job_name) if job is None: raise AssertionError(f"Job '{job_name}' not found in workflow") needs = job.get("needs", []) if isinstance(needs, str): needs = [needs] if dependency not in needs: raise AssertionError( f"Job '{job_name}' does not depend on '{dependency}'. Dependencies: {needs}" ) @then("the workflow should reference these nox sessions:") def step_then_workflow_references_nox_sessions(context): """Verify all required nox sessions are referenced in the workflow.""" jobs = context.ci_workflow.get("jobs", {}) # Collect all run commands across all jobs all_run_commands = "" for job_data in jobs.values(): for step in job_data.get("steps", []): run_cmd = step.get("run", "") all_run_commands += run_cmd + "\n" for row in context.table: session_name = row["session"] nox_call = f"nox -s {session_name}" if nox_call not in all_run_commands: raise AssertionError( f"Nox session '{session_name}' (as '{nox_call}') " f"not found in any CI workflow step" ) @then("the workflow YAML should be valid") def step_then_workflow_yaml_valid(context): """Verify the parsed YAML is a valid workflow (has jobs key).""" if context.ci_workflow is None: raise AssertionError("Workflow YAML parsed as None (empty file)") if "jobs" not in context.ci_workflow: raise AssertionError("Workflow YAML missing 'jobs' key") if not isinstance(context.ci_workflow["jobs"], dict): raise AssertionError("Workflow 'jobs' is not a mapping") @then('the workflow should trigger on push tags matching "{pattern}"') def step_then_workflow_triggers_on_tags(context, pattern): """Verify the workflow triggers on push tags matching a pattern.""" on_config = context.ci_workflow.get("on", context.ci_workflow.get(True, {})) push_config = on_config.get("push", {}) tags = push_config.get("tags", []) if pattern not in tags: raise AssertionError(f"Tag pattern '{pattern}' not found in push tags: {tags}") @then("the coverage job should enforce a 97% threshold") def step_then_coverage_threshold_97(context): """Verify the coverage job references the 97% threshold.""" jobs = context.ci_workflow.get("jobs", {}) coverage_job = jobs.get("coverage") if coverage_job is None: raise AssertionError("Coverage job not found in workflow") steps = coverage_job.get("steps", []) all_run_commands = "\n".join(step.get("run", "") for step in steps if "run" in step) if "97" not in all_run_commands: raise AssertionError( "Coverage job does not reference 97% threshold in any step" ) @then('the workflow should trigger on push to "{branch}"') def step_then_workflow_triggers_on_push(context, branch): """Verify the workflow triggers on push to a specific branch.""" on_config = context.ci_workflow.get("on", context.ci_workflow.get(True, {})) push_config = on_config.get("push", {}) branches = push_config.get("branches", []) if branch not in branches: raise AssertionError( f"Branch '{branch}' not found in push branches: {branches}" ) @then('the workflow should trigger on pull_request to "{branch}"') def step_then_workflow_triggers_on_pr(context, branch): """Verify the workflow triggers on pull_request to a specific branch.""" on_config = context.ci_workflow.get("on", context.ci_workflow.get(True, {})) pr_config = on_config.get("pull_request", {}) branches = pr_config.get("branches", []) if branch not in branches: raise AssertionError( f"Branch '{branch}' not found in pull_request branches: {branches}" ) @then("at least one job should use actions/cache") def step_then_at_least_one_job_uses_cache(context): """Verify at least one job uses actions/cache for dependency caching.""" jobs = context.ci_workflow.get("jobs", {}) for job_data in jobs.values(): for step in job_data.get("steps", []): uses = step.get("uses", "") if "actions/cache" in uses: return raise AssertionError( "No job in the CI workflow uses actions/cache for dependency caching" )