flowproof 0.1.0__cp39-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowproof/__init__.py +38 -0
- flowproof/_native.pyd +0 -0
- flowproof/cli.py +16 -0
- flowproof/flow.py +190 -0
- flowproof/mcp_server.py +68 -0
- flowproof/py.typed +0 -0
- flowproof-0.1.0.dist-info/METADATA +62 -0
- flowproof-0.1.0.dist-info/RECORD +11 -0
- flowproof-0.1.0.dist-info/WHEEL +4 -0
- flowproof-0.1.0.dist-info/entry_points.txt +3 -0
- flowproof-0.1.0.dist-info/sboms/flowproof-python.cyclonedx.json +10943 -0
flowproof/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""FlowProof — AI-native E2E testing for the apps Selenium can't reach.
|
|
2
|
+
|
|
3
|
+
Open-source AI-native E2E testing framework for enterprise Windows apps
|
|
4
|
+
(SAP GUI, Oracle, Citrix, legacy desktop). AI authors a flow once from a
|
|
5
|
+
natural-language spec and records a trace; a deterministic engine replays
|
|
6
|
+
the trace in CI with zero LLM calls.
|
|
7
|
+
|
|
8
|
+
The Rust engine ships inside this package as the `flowproof._native`
|
|
9
|
+
extension module; the `flowproof` command drives it.
|
|
10
|
+
See https://github.com/automators-com/flowproof
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from flowproof.flow import (
|
|
14
|
+
Flow,
|
|
15
|
+
HealResult,
|
|
16
|
+
RecordResult,
|
|
17
|
+
RunResult,
|
|
18
|
+
StepResult,
|
|
19
|
+
get_trace,
|
|
20
|
+
heal,
|
|
21
|
+
record,
|
|
22
|
+
run,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Flow",
|
|
29
|
+
"HealResult",
|
|
30
|
+
"RecordResult",
|
|
31
|
+
"RunResult",
|
|
32
|
+
"StepResult",
|
|
33
|
+
"__version__",
|
|
34
|
+
"get_trace",
|
|
35
|
+
"heal",
|
|
36
|
+
"record",
|
|
37
|
+
"run",
|
|
38
|
+
]
|
flowproof/_native.pyd
ADDED
|
Binary file
|
flowproof/cli.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Console entry point: delegates to the Rust CLI inside the extension
|
|
2
|
+
module so `flowproof` behaves identically however the engine is invoked."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from flowproof import _native
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
sys.exit(_native.cli_main(sys.argv[1:]))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
flowproof/flow.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Public API surface of the flowproof SDK, backed by the Rust engine.
|
|
2
|
+
|
|
3
|
+
Designed for programmatic callers (typically AI agents): every operation
|
|
4
|
+
returns structured data — never something that has to be scraped out of
|
|
5
|
+
stdout. The ``flowproof`` CLI wraps these same code paths for humans.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from flowproof import _native
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RecordResult:
|
|
20
|
+
"""Outcome of recording a flow."""
|
|
21
|
+
|
|
22
|
+
trace_path: Path
|
|
23
|
+
steps: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class StepResult:
|
|
28
|
+
"""One replayed step. ``status`` is ``passed``, ``failed`` or ``skipped``."""
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
intent: str
|
|
32
|
+
status: str
|
|
33
|
+
duration_ms: int
|
|
34
|
+
detail: str | None = None
|
|
35
|
+
started_ms: int = 0
|
|
36
|
+
"""Offset from run start — with duration_ms, the step→time mapping into
|
|
37
|
+
the run's recording."""
|
|
38
|
+
selector_tier: str | None = None
|
|
39
|
+
"""Selector-ladder tier that resolved the target (native_id,
|
|
40
|
+
structural, text_anchor)."""
|
|
41
|
+
degraded: bool = False
|
|
42
|
+
"""True when a fallback rung matched instead of the recorded primary
|
|
43
|
+
selector — the step ran, but the trace should be healed."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class RunResult:
|
|
48
|
+
"""Structured outcome of a replay. Truthy exactly when the flow passed."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
trace_id: str
|
|
52
|
+
passed: bool
|
|
53
|
+
duration_ms: int
|
|
54
|
+
steps: tuple[StepResult, ...]
|
|
55
|
+
report_path: Path
|
|
56
|
+
html_path: Path
|
|
57
|
+
"""Human-readable rendering generated from the JSON report."""
|
|
58
|
+
recording: dict[str, Any] | None = None
|
|
59
|
+
"""The run's recording bundle: format, frame refs, per-step time ranges
|
|
60
|
+
(None when the driver cannot capture)."""
|
|
61
|
+
degraded: bool = False
|
|
62
|
+
"""True when any step resolved via a fallback selector rung: the run
|
|
63
|
+
passed, but the app drifted from the trace — schedule a heal."""
|
|
64
|
+
|
|
65
|
+
def __bool__(self) -> bool:
|
|
66
|
+
return self.passed
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def junit_path(self) -> Path:
|
|
70
|
+
"""JUnit XML rendering of this run, for CI systems that ingest it
|
|
71
|
+
(Jenkins, GitLab, Azure DevOps, ...). Written alongside
|
|
72
|
+
``result.json``."""
|
|
73
|
+
return self.report_path.with_name("junit.xml")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_run_result(payload: str) -> RunResult:
|
|
77
|
+
data = json.loads(payload)
|
|
78
|
+
report = data["report"]
|
|
79
|
+
return RunResult(
|
|
80
|
+
name=report["name"],
|
|
81
|
+
trace_id=report["trace_id"],
|
|
82
|
+
passed=report["passed"],
|
|
83
|
+
duration_ms=report["duration_ms"],
|
|
84
|
+
steps=tuple(
|
|
85
|
+
StepResult(
|
|
86
|
+
id=s["id"],
|
|
87
|
+
intent=s["intent"],
|
|
88
|
+
status=s["status"],
|
|
89
|
+
duration_ms=s["duration_ms"],
|
|
90
|
+
detail=s.get("detail"),
|
|
91
|
+
started_ms=s.get("started_ms", 0),
|
|
92
|
+
selector_tier=s.get("selector_tier"),
|
|
93
|
+
degraded=s.get("degraded", False),
|
|
94
|
+
)
|
|
95
|
+
for s in report["steps"]
|
|
96
|
+
),
|
|
97
|
+
report_path=Path(data["report_path"]),
|
|
98
|
+
html_path=Path(data["report_path"]).with_name("report.html"),
|
|
99
|
+
recording=report.get("recording"),
|
|
100
|
+
degraded=report.get("degraded", False),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class HealResult:
|
|
106
|
+
"""Outcome of a heal pass: a PROPOSED trace diff, never a silent fix."""
|
|
107
|
+
|
|
108
|
+
changed: bool
|
|
109
|
+
steps_changed: tuple[dict[str, Any], ...]
|
|
110
|
+
steps_added: int
|
|
111
|
+
steps_removed: int
|
|
112
|
+
proposed_path: Path | None
|
|
113
|
+
applied: bool
|
|
114
|
+
diff_html: Path | None = None
|
|
115
|
+
"""Human review page: before/after per changed step, with frames."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_heal_result(payload: str) -> HealResult:
|
|
119
|
+
data = json.loads(payload)
|
|
120
|
+
report = data["report"]
|
|
121
|
+
proposed = report.get("proposed_path")
|
|
122
|
+
diff_html = report.get("diff_html")
|
|
123
|
+
return HealResult(
|
|
124
|
+
changed=report["changed"],
|
|
125
|
+
steps_changed=tuple(report["steps_changed"]),
|
|
126
|
+
steps_added=report["steps_added"],
|
|
127
|
+
steps_removed=report["steps_removed"],
|
|
128
|
+
proposed_path=Path(proposed) if proposed else None,
|
|
129
|
+
applied=data["applied"],
|
|
130
|
+
diff_html=Path(diff_html) if diff_html else None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Flow:
|
|
136
|
+
"""A flow, defined by a YAML spec with natural-language steps."""
|
|
137
|
+
|
|
138
|
+
spec: Path
|
|
139
|
+
|
|
140
|
+
def __init__(self, spec: str | Path) -> None:
|
|
141
|
+
object.__setattr__(self, "spec", Path(spec))
|
|
142
|
+
|
|
143
|
+
def record(self, out: str | Path | None = None) -> RecordResult:
|
|
144
|
+
"""Perform the flow once against the live app and write a trace.
|
|
145
|
+
|
|
146
|
+
Requires Windows and the target app.
|
|
147
|
+
"""
|
|
148
|
+
data = json.loads(_native.record(self.spec, Path(out) if out else None))
|
|
149
|
+
return RecordResult(trace_path=Path(data["trace_path"]), steps=data["steps"])
|
|
150
|
+
|
|
151
|
+
def run(self, trace: str | Path | None = None) -> RunResult:
|
|
152
|
+
"""Deterministically replay the recorded trace (zero LLM calls).
|
|
153
|
+
|
|
154
|
+
A failing test is a ``RunResult`` with ``passed=False``, not an
|
|
155
|
+
exception; ``RuntimeError`` means the run could not execute at all
|
|
156
|
+
(missing trace, unsupported platform, ...).
|
|
157
|
+
"""
|
|
158
|
+
return _parse_run_result(_native.run(self.spec, Path(trace) if trace else None))
|
|
159
|
+
|
|
160
|
+
def get_trace(self, trace: str | Path | None = None) -> dict[str, Any]:
|
|
161
|
+
"""Load the recorded trace for inspection: ``{"header": …, "steps": […]}``."""
|
|
162
|
+
return json.loads(_native.get_trace(Path(trace) if trace else self.spec))
|
|
163
|
+
|
|
164
|
+
def heal(self, trace: str | Path | None = None, apply: bool = False) -> HealResult:
|
|
165
|
+
"""Re-author the flow against the live app and propose a trace diff.
|
|
166
|
+
|
|
167
|
+
Never modifies the trace unless ``apply=True`` is passed explicitly;
|
|
168
|
+
the proposal lands next to the trace as ``*.proposed.jsonl``.
|
|
169
|
+
"""
|
|
170
|
+
return _parse_heal_result(_native.heal(self.spec, Path(trace) if trace else None, apply))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def record(spec: str | Path, out: str | Path | None = None) -> RecordResult:
|
|
174
|
+
"""Record a flow from a YAML spec. See :meth:`Flow.record`."""
|
|
175
|
+
return Flow(spec).record(out)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run(spec: str | Path, trace: str | Path | None = None) -> RunResult:
|
|
179
|
+
"""Replay a recorded flow deterministically. See :meth:`Flow.run`."""
|
|
180
|
+
return Flow(spec).run(trace)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_trace(path: str | Path) -> dict[str, Any]:
|
|
184
|
+
"""Load a trace (from a spec path or a ``.jsonl`` file) for inspection."""
|
|
185
|
+
return json.loads(_native.get_trace(Path(path)))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def heal(spec: str | Path, trace: str | Path | None = None, apply: bool = False) -> HealResult:
|
|
189
|
+
"""Propose a heal diff for a stale trace. See :meth:`Flow.heal`."""
|
|
190
|
+
return Flow(spec).heal(trace, apply)
|
flowproof/mcp_server.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""flowproof as MCP tools: record, run, get_trace, heal over stdio.
|
|
2
|
+
|
|
3
|
+
Lets any MCP-capable agent (Claude, opencode, DataMaker, ...) drive
|
|
4
|
+
flowproof directly. Thin wrappers over the same engine the CLI uses;
|
|
5
|
+
every tool takes and returns JSON-serializable data.
|
|
6
|
+
|
|
7
|
+
Requires the ``mcp`` extra: ``pip install flowproof[mcp]`` (Python 3.10+).
|
|
8
|
+
Run with ``flowproof-mcp`` or ``python -m flowproof.mcp_server``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from flowproof import _native
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
except ImportError as exc: # pragma: no cover - exercised via main() guard
|
|
21
|
+
FastMCP = None
|
|
22
|
+
_IMPORT_ERROR = exc
|
|
23
|
+
|
|
24
|
+
if FastMCP is not None:
|
|
25
|
+
mcp = FastMCP("flowproof")
|
|
26
|
+
|
|
27
|
+
@mcp.tool()
|
|
28
|
+
def flowproof_record(spec: str, out: str | None = None) -> dict[str, Any]:
|
|
29
|
+
"""Record a flow: perform it once against the live app and write a
|
|
30
|
+
deterministic trace. Requires the target platform (Windows for UIA
|
|
31
|
+
apps like calc/notepad; any OS for `app: web`). Returns
|
|
32
|
+
{"trace_path", "steps"}."""
|
|
33
|
+
return json.loads(_native.record(spec, out))
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
def flowproof_run(spec: str, trace: str | None = None) -> dict[str, Any]:
|
|
37
|
+
"""Deterministically replay a recorded flow (zero LLM calls). A
|
|
38
|
+
failing test is data ({"report": {"passed": false, ...}}), not an
|
|
39
|
+
error. Returns {"report", "report_path"}."""
|
|
40
|
+
return json.loads(_native.run(spec, trace))
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
def flowproof_get_trace(path: str) -> dict[str, Any]:
|
|
44
|
+
"""Load a recorded trace for inspection. `path` may be the flow spec
|
|
45
|
+
(the default trace next to it is used) or a .jsonl trace file.
|
|
46
|
+
Returns {"header", "steps"}."""
|
|
47
|
+
return json.loads(_native.get_trace(path))
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
def flowproof_heal(spec: str, trace: str | None = None, apply: bool = False) -> dict[str, Any]:
|
|
51
|
+
"""Re-author the flow against the live app and propose a reviewable
|
|
52
|
+
trace diff (written as *.proposed.jsonl). Never modifies the trace
|
|
53
|
+
unless apply=true is passed explicitly. Returns
|
|
54
|
+
{"report": {"changed", "steps_changed", ...}, "applied"}."""
|
|
55
|
+
return json.loads(_native.heal(spec, trace, apply))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> None:
|
|
59
|
+
if FastMCP is None:
|
|
60
|
+
raise SystemExit(
|
|
61
|
+
"flowproof-mcp requires the 'mcp' extra (Python 3.10+): "
|
|
62
|
+
f"pip install 'flowproof[mcp]' ({_IMPORT_ERROR})"
|
|
63
|
+
)
|
|
64
|
+
mcp.run()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
flowproof/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flowproof
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
5
|
+
Classifier: Intended Audience :: Developers
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Rust
|
|
8
|
+
Classifier: Topic :: Software Development :: Testing
|
|
9
|
+
Requires-Dist: mcp>=1.2 ; python_full_version >= '3.10' and extra == 'mcp'
|
|
10
|
+
Provides-Extra: mcp
|
|
11
|
+
Summary: AI-native E2E testing for the apps Selenium can't reach — SAP GUI, Oracle Forms, Citrix, Windows desktop.
|
|
12
|
+
Author-email: Automators <hello@automators.com>
|
|
13
|
+
License-Expression: Apache-2.0
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
16
|
+
Project-URL: Homepage, https://github.com/automators-com/flowproof
|
|
17
|
+
Project-URL: Repository, https://github.com/automators-com/flowproof
|
|
18
|
+
|
|
19
|
+
# flowproof
|
|
20
|
+
|
|
21
|
+
AI-native E2E testing for the apps Selenium can't reach — SAP GUI, Oracle
|
|
22
|
+
Forms, Citrix, and any Windows desktop app.
|
|
23
|
+
|
|
24
|
+
**AI authors, deterministic engine executes.** A flow is described in YAML
|
|
25
|
+
with natural-language steps, recorded once against the live app, and replayed
|
|
26
|
+
deterministically in CI with zero LLM calls. Built agent-first: every
|
|
27
|
+
operation returns structured results a program can reason over.
|
|
28
|
+
|
|
29
|
+
```yaml
|
|
30
|
+
# calc.flow.yaml
|
|
31
|
+
name: Add two numbers
|
|
32
|
+
app: calc
|
|
33
|
+
steps:
|
|
34
|
+
- Type 5
|
|
35
|
+
- Press plus
|
|
36
|
+
- Type 3
|
|
37
|
+
- Press equals
|
|
38
|
+
- assert: display shows 8
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from flowproof import Flow
|
|
43
|
+
|
|
44
|
+
flow = Flow("calc.flow.yaml")
|
|
45
|
+
flow.record() # performs the flow live (Windows), writes the trace
|
|
46
|
+
result = flow.run() # deterministic replay -> RunResult
|
|
47
|
+
assert result.passed
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or from the shell: `flowproof record calc.flow.yaml`, then
|
|
51
|
+
`flowproof run calc.flow.yaml` (add `--json` for the structured report).
|
|
52
|
+
|
|
53
|
+
The wheel bundles the Rust engine (Windows UI Automation driver); no separate
|
|
54
|
+
install. Windows is required to record/run flows — the package imports fine
|
|
55
|
+
elsewhere for inspection and tooling.
|
|
56
|
+
|
|
57
|
+
**Status: early (v0.1)** — the deterministic record→replay spine, proven on
|
|
58
|
+
Windows Calculator. LLM authoring agents, SAP GUI adapter, Citrix vision
|
|
59
|
+
mode, and self-healing are on the roadmap.
|
|
60
|
+
|
|
61
|
+
Docs and source: [github.com/automators-com/flowproof](https://github.com/automators-com/flowproof)
|
|
62
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
flowproof/__init__.py,sha256=OulOoeVDJnBsQiNthUE3ykLTubS9csVcdgGckoN8h_k,893
|
|
2
|
+
flowproof/_native.pyd,sha256=V5W-EGXNRwmCVMC3StsmegUV4lQW6D6qPVFTjCRBEPc,15764480
|
|
3
|
+
flowproof/cli.py,sha256=n3JQGOgfoQV67ED4aeAZ2NQCyOOriborpXnd4DASPWE,349
|
|
4
|
+
flowproof/flow.py,sha256=PzHgwrdt6HjoGTgUt0m7cd4-N7Y6HdTuxlaLixbmpM0,6772
|
|
5
|
+
flowproof/mcp_server.py,sha256=4M-2lciAxXZltzztWMRWK6Mhtsubix2f2Dr394gOnzA,2580
|
|
6
|
+
flowproof/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
flowproof-0.1.0.dist-info/METADATA,sha256=DvcFeS3XbYBWuit9lwLc9ur1eLmk5cUza4swZnP1-WQ,2281
|
|
8
|
+
flowproof-0.1.0.dist-info/WHEEL,sha256=_rgZdQc9sLFEqt2IemX7qyxsGt5fFRnBDATxGzerdmg,95
|
|
9
|
+
flowproof-0.1.0.dist-info/entry_points.txt,sha256=pSWgAeBHdGFEQ0htyNsQykPg2OdPrXYpQ0hiOwg20wA,87
|
|
10
|
+
flowproof-0.1.0.dist-info/sboms/flowproof-python.cyclonedx.json,sha256=warBanOkF_UbFycEJETG-JnLTK0eNUibRTfSqpQsWy0,397961
|
|
11
|
+
flowproof-0.1.0.dist-info/RECORD,,
|