Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions project/TICKETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ This file indexes governance tickets without taking ownership of
| **ticket-024** | [`README.md`](./ticket-024/README.md) | - | - | - | - | - |
| **ticket-025** | [`README.md`](./ticket-025/README.md) | - | - | - | - | - |
| **ticket-026** | [`README.md`](./ticket-026/README.md) | - | - | - | - | - |
| **ticket-030** | [`README.md`](./ticket-030/README.md) | - | - | - | - | - |
<!-- AUTO:TICKET_INDEX:END -->
24 changes: 24 additions & 0 deletions project/ticket-030/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Ticket 030: Honor explicit TestQL target and validate dry runs

- **ID**: ticket-030
- **Owner**: agent:codex
- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Created**: 2026-10-26

## Goal and scope

Continue autogrammar/testql STARTER-023, discovered during maskservice PLF-17350/17354. Honor an explicit CLI --url when scenario CONFIG supplies a base_url default. Preserve ordinary SET behavior and scenario targets when no explicit override is supplied. Validate dry runs without claiming runtime assertion success or contacting devices.

SESSION_EXECUTION_AUTHORIZATION: The operator requested continued repair and testing after reviewing the hardware verification report. This authorizes bounded implementation, tests and protected publication; independent approval remains required.

## Acceptance criteria

- [x] AC-01: A two-server HTTP regression proves CONFIG cannot redirect requests away from an explicit CLI target.
- [x] AC-02: Scenario defaults and ordinary variable assignments keep their documented behavior without a CLI override.
- [x] AC-03: Dry-run validates response assertions without evaluating unavailable runtime responses or sending network requests.
- [ ] AC-04: Focused regressions and managed governance pass; publication is independently verified.

## Validation

114 focused target-selection, interpreter, TestTOON, HTTP, assertion and dry-run tests pass with managed governance. All 19 device E2E scenarios validate in dry-run (428 validated steps, zero executed). Two independent local HTTP servers prove the CLI-selected target receives requests and the scenario default receives none. Independent protected publication remains pending. Raw results stay in ignored operational receipts.
105 changes: 105 additions & 0 deletions project/ticket-030/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-030",
"summary": "Honor explicit TestQL target and validate dry runs without runtime assertions",
"workstream": "core",
"classification": {
"kind": "BUG",
"priority": "P1",
"origin": "regression"
},
"allowedPaths": [
"project/ticket-030/**",
"TODO.md",
"project/TICKETS.md",
"testql/commands/run_cmd.py",
"testql/interpreter/**",
"tests/test_dry_run_validation.py",
"tests/test_explicit_target.py",
"tests/test_navigate_json_path.py"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [
"python"
],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "5dc442a173b3c39684d37ae7ed5e309c04244d14",
"targetBranch": "main",
"outcome": "Honor explicit CLI base_url throughout scenario assignments and report dry-run syntax validation separately from execution.",
"nonGoals": [
"No physical actuator commands or firmware deployment",
"No forced rewriting of absolute URLs for other explicitly named services",
"No node configuration or protected policy changes"
],
"complexity": "M",
"estimatedMinutes": 60,
"budgets": {
"maxImplementationFiles": 6,
"maxAffectedComponents": 2,
"maxPublicInterfaceChanges": 1,
"maxRuntimeDependencies": 0
},
"architecture": {
"status": "accepted",
"decision": "Pin the CLI base_url override in the interpreter; validate response assertion syntax in dry-run and expose separate validated/executed metrics while retaining legacy step status compatibility.",
"components": [
{
"name": "cli",
"paths": [
"testql/commands/run_cmd.py"
]
},
{
"name": "interpreter",
"paths": [
"testql/interpreter/**",
"tests/test_explicit_target.py",
"tests/test_dry_run_validation.py",
"tests/test_navigate_json_path.py"
]
}
],
"responsibilityChanges": false,
"interfaceChanges": [
"Optional interpreter base_url_override argument; explicit CLI target has precedence over CONFIG/SET."
],
"dataChanges": [],
"ui": {
"impact": "none",
"states": [],
"evidence": []
},
"rollback": "Revert ticket-030 source commit; keep hardware settings and installed runtime unchanged until qualified publication."
},
"runtimeDependencies": [],
"validation": [
{
"criterion": "AC-01",
"commands": [
"pytest tests/test_explicit_target.py"
],
"evidence": "Two real local servers and INCLUDE regression prove only the CLI target is contacted."
},
{
"criterion": "AC-03",
"commands": [
"pytest tests/test_dry_run_validation.py"
],
"evidence": "No HTTP execution, zero executed steps, malformed syntax rejected and live assertion failures preserved."
},
{
"criterion": "AC-04",
"commands": [
"bash project/governance-check.sh",
"pytest tests/test_interpreter.py tests/test_run_cmd.py tests/test_api_optional_unreachable.py"
],
"evidence": "99 focused regressions and managed governance pass; protected exact-head review remains required."
}
]
}
}
10 changes: 8 additions & 2 deletions testql/commands/run_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,24 @@ def _run_single(
include_paths=[str(path.parent), "."],
timeout_ms=timeout,
strict_commands=not allow_semantic_events,
base_url_override=url or None,
)
return interp.run(source, path.name)


def _runtime_passed(result) -> int:
"""Legacy interpreter dry-run step successes are validations, not execution."""
return 0 if getattr(result, "executed", None) == 0 else result.passed


def _emit_single_json(result) -> None:
failures = _failure_details(result)
print(
json.dumps(
{
"source": result.source,
"ok": result.ok,
"passed": result.passed,
"passed": _runtime_passed(result),
"failed": result.failed,
"steps": len(result.steps),
"skipped": getattr(result, "skipped", 0),
Expand Down Expand Up @@ -164,7 +170,7 @@ def _emit_multi_json(results: list[tuple[Path, object]]) -> None:
"file": str(path),
"source": result.source,
"ok": ok,
"passed": result.passed,
"passed": _runtime_passed(result),
"failed": result.failed,
"steps": len(result.steps),
"skipped": getattr(result, "skipped", 0),
Expand Down
52 changes: 34 additions & 18 deletions testql/interpreter/_assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from typing import Any
import json
from pathlib import Path

from testql.base import StepResult, StepStatus

Expand All @@ -26,6 +27,16 @@
class AssertionsMixin:
"""Mixin providing ASSERT_STATUS, ASSERT_OK, ASSERT_CONTAINS, ASSERT_JSON."""

def _validate_dry_response_assertion(self, name: str) -> bool:
if not self.dry_run:
return False
self.out.step("⏭️", f"{name} (dry-run: syntax validated)")
self.results.append(StepResult(
name=name, status=StepStatus.SKIPPED,
message="dry-run: response assertion syntax validated; value not evaluated",
))
return True

def _skip_if_optional_unreachable(self, name: str) -> bool:
"""Skip response assertions after an optional API row that never connected."""
if not getattr(self, "_skip_response_asserts", False):
Expand All @@ -41,9 +52,7 @@ def _cmd_assert_status(self, args: str, line: OqlLine) -> None:
name = f"ASSERT_STATUS {expected}"
if self._skip_if_optional_unreachable(name):
return
if getattr(self, "dry_run", False):
self.out.step(" ✅", f"{name} (dry-run)")
self.results.append(StepResult(name=name, status=StepStatus.PASSED))
if self._validate_dry_response_assertion(name):
return
ok = self.last_status == expected
if ok:
Expand All @@ -62,9 +71,7 @@ def _cmd_assert_ok(self, args: str, line: OqlLine) -> None:
name = "ASSERT_OK"
if self._skip_if_optional_unreachable(name):
return
if getattr(self, "dry_run", False):
self.out.step(" ✅", f"{name} (dry-run)")
self.results.append(StepResult(name=name, status=StepStatus.PASSED))
if self._validate_dry_response_assertion(name):
return
ok = 200 <= self.last_status < 300
if ok:
Expand All @@ -85,9 +92,7 @@ def _cmd_assert_contains(self, args: str, line: OqlLine) -> None:
name = f'ASSERT_CONTAINS "{needle}"'
if self._skip_if_optional_unreachable(name):
return
if getattr(self, "dry_run", False):
self.out.step(" ✅", f"{name} (dry-run)")
self.results.append(StepResult(name=name, status=StepStatus.PASSED))
if self._validate_dry_response_assertion(name):
return
haystack = _json.dumps(self.last_response or {})
ok = needle in haystack
Expand All @@ -108,11 +113,14 @@ def _cmd_assert_json(self, args: str, line: OqlLine) -> None:

parts = args.strip().split(None, 2)
if len(parts) < 3:
self.out.warn(f"L{line.number}: ASSERT_JSON requires <path> <op> <value>")
return
raise ValueError("ASSERT_JSON requires <path> <op> <value>")

path, op, expected_str = parts[0], parts[1], parts[2]
desc = f"ASSERT_JSON {path} {op} {expected_str}"
if op not in _COMPARE_OPS:
raise ValueError(f"Unsupported ASSERT_JSON operator: {op}")
if self._validate_dry_response_assertion(desc):
return
if not path.startswith("_") or path in {"_status", "_response", "_headers"}:
if self._skip_if_optional_unreachable(desc):
return
Expand Down Expand Up @@ -204,6 +212,9 @@ def _cmd_assert_schema(self, args: str, line: OqlLine) -> None:
))
return

if self._validate_dry_response_assertion("ASSERT_SCHEMA"):
return

# Validate response against schema
try:
import jsonschema
Expand Down Expand Up @@ -234,16 +245,15 @@ def _cmd_assert_headers(self, args: str, line: OqlLine) -> None:
"""
parts = args.strip().split(None, 2)
if len(parts) < 3:
self.out.warn(f"L{line.number}: ASSERT_HEADERS requires <header> <op> <value>")
return
raise ValueError("ASSERT_HEADERS requires <header> <op> <value>")

header_name, op, expected = parts[0], parts[1], parts[2].strip("\"'")
desc = f"ASSERT_HEADERS {header_name} {op} {expected}"
if op not in _COMPARE_OPS and op != "contains":
raise ValueError(f"Unsupported ASSERT_HEADERS operator: {op}")
if self._skip_if_optional_unreachable(desc):
return
if getattr(self, "dry_run", False):
self.out.step(" ✅", f"{desc} (dry-run)")
self.results.append(StepResult(name=desc, status=StepStatus.PASSED))
if self._validate_dry_response_assertion(desc):
return

# Get headers from variable (set by API runner)
Expand Down Expand Up @@ -285,12 +295,18 @@ def _cmd_assert_cookies(self, args: str, line: OqlLine) -> None:
"""
parts = args.strip().split(None, 2)
if len(parts) < 2:
self.out.warn(f"L{line.number}: ASSERT_COOKIES requires <cookie> <op> [value]")
return
raise ValueError("ASSERT_COOKIES requires <cookie> <op> [value]")

cookie_name = parts[0]
op = parts[1]
expected = parts[2] if len(parts) > 2 else None
if op not in {"exists", "not_exists"}:
if op not in _COMPARE_OPS and op != "contains":
raise ValueError(f"Unsupported ASSERT_COOKIES operator: {op}")
if expected is None:
raise ValueError(f"ASSERT_COOKIES requires value for op '{op}'")
if self._validate_dry_response_assertion(f"ASSERT_COOKIES {args}"):
return

# Get cookies from headers variable
headers = self.vars.get("_headers", {})
Expand Down
41 changes: 36 additions & 5 deletions testql/interpreter/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@
from .dispatcher import CommandDispatcher


class _DryRunResult(ScriptResult):
"""Expose validation counts while preserving legacy per-step statuses."""

@property
def validated(self) -> int:
return sum(step.status in (StepStatus.PASSED, StepStatus.SKIPPED) for step in self.steps)

@property
def executed(self) -> int:
return 0

@property
def skipped(self) -> int:
return sum(step.status == StepStatus.SKIPPED for step in self.steps)

def summary(self) -> str:
icon = "✅" if self.ok else "❌"
return (f"{icon} {self.source}: {self.validated}/{len(self.steps)} validated, "
f"{self.failed} failed, 0 executed ({self.duration_ms:.0f}ms)")


class OqlInterpreter(ApiRunnerMixin, AssertionsMixin, ContextMixin, EncoderMixin, FlowMixin, GuiMixin, BrowserPerformanceMixin, DesktopMixin, DomScanMixin, HardwareMixin, ModbusMixin, ScannerMixin, ShellMixin, UnitMixin, ValidationMixin, WebSocketMixin, BaseInterpreter):
"""
OQL interpreter — runs .testql.toon.yaml / .oql / .tql scripts.
Expand Down Expand Up @@ -63,6 +84,7 @@ def __init__(
bridge_url: str | None = None,
timeout_ms: int | None = None,
strict_commands: bool = False,
base_url_override: str | None = None,
):
# Handle bridge_url safely (BaseInterpreter in oqlos might not support it yet)
try:
Expand All @@ -71,6 +93,9 @@ def __init__(
super().__init__(variables=variables, quiet=quiet)
self.bridge_url = bridge_url
self.api_url = api_url
self.base_url_override = base_url_override
if base_url_override is not None:
self.vars.set("base_url", base_url_override)
self.dry_run = dry_run
self.include_paths = include_paths or ["."]
self.timeout_ms = timeout_ms
Expand Down Expand Up @@ -131,7 +156,8 @@ def execute(self, parsed: OqlScript) -> ScriptResult:
reported_variables[name] = "***REDACTED***"
reported_variables = self.redact(reported_variables)
self._redact_results()
sr = ScriptResult(
result_type = _DryRunResult if self.dry_run else ScriptResult
sr = result_type(
source=parsed.filename, ok=ok, steps=self.results,
variables=reported_variables, errors=self.errors,
warnings=self.warnings, duration_ms=elapsed,
Expand Down Expand Up @@ -168,6 +194,13 @@ def _dispatch(self, cmd: str, args: str, line: OqlLine) -> None:

# ── Variables ─────────────────────────────────────────────────────────────

def _set_scenario_variable(self, key: str, value: str) -> None:
# An explicit operator target outranks CONFIG and SET, including INCLUDEs.
if key == "base_url" and self.base_url_override is not None:
value = self.base_url_override
self.vars.set(key, value)
self.out.step("📝", f'SET "{key}" = "{value}"')

def _cmd_set(self, args: str, line: OqlLine) -> None:
# Handle quoted keys with spaces: SET "key with spaces" "value"
import shlex
Expand All @@ -182,17 +215,15 @@ def _cmd_set(self, args: str, line: OqlLine) -> None:
val = val[1:-1]
elif val.startswith("'") and val.endswith("'"):
val = val[1:-1]
self.vars.set(key, val)
self.out.step("📝", f'SET "{key}" = "{val}"')
self._set_scenario_variable(key, val)
except ValueError:
# Fallback to simple split if shlex fails
parts = args.split(None, 1)
if len(parts) < 2:
self.out.warn(f"L{line.number}: SET requires <name> <value>")
return
key, val = parts[0], parts[1].strip().strip('"\'')
self.vars.set(key, val)
self.out.step("📝", f"SET {key} = {val}")
self._set_scenario_variable(key, val)

def _cmd_get(self, args: str, line: OqlLine) -> None:
key = args.strip()
Expand Down
Loading
Loading