flow2skill 0.1.0__py3-none-any.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.
- flow2skill/__init__.py +6 -0
- flow2skill/__main__.py +3 -0
- flow2skill/cli.py +212 -0
- flow2skill/exporter.py +359 -0
- flow2skill/model.py +398 -0
- flow2skill/parser.py +407 -0
- flow2skill/recorder.py +298 -0
- flow2skill/replay.py +153 -0
- flow2skill/server.py +335 -0
- flow2skill/ui/demo_form.html +36 -0
- flow2skill/ui/index.html +274 -0
- flow2skill-0.1.0.dist-info/METADATA +233 -0
- flow2skill-0.1.0.dist-info/RECORD +17 -0
- flow2skill-0.1.0.dist-info/WHEEL +5 -0
- flow2skill-0.1.0.dist-info/entry_points.txt +2 -0
- flow2skill-0.1.0.dist-info/licenses/LICENSE +21 -0
- flow2skill-0.1.0.dist-info/top_level.txt +1 -0
flow2skill/parser.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .model import (
|
|
8
|
+
ANY_PLACEHOLDER_SCAN_RE,
|
|
9
|
+
PLACEHOLDER_SCAN_RE,
|
|
10
|
+
Action,
|
|
11
|
+
FlowValidationError,
|
|
12
|
+
Selector,
|
|
13
|
+
Workflow,
|
|
14
|
+
classify_risk,
|
|
15
|
+
redact_url,
|
|
16
|
+
sanitize_fill_value,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_MISSING = object()
|
|
20
|
+
|
|
21
|
+
SELECTOR_METHODS = {
|
|
22
|
+
"get_by_role": "role",
|
|
23
|
+
"get_by_label": "label",
|
|
24
|
+
"get_by_placeholder": "placeholder",
|
|
25
|
+
"get_by_text": "text",
|
|
26
|
+
"get_by_test_id": "test_id",
|
|
27
|
+
"get_by_title": "title",
|
|
28
|
+
"get_by_alt_text": "alt_text",
|
|
29
|
+
"locator": "css",
|
|
30
|
+
}
|
|
31
|
+
ACTION_METHODS = {
|
|
32
|
+
"goto",
|
|
33
|
+
"click",
|
|
34
|
+
"fill",
|
|
35
|
+
"press",
|
|
36
|
+
"select_option",
|
|
37
|
+
"check",
|
|
38
|
+
"uncheck",
|
|
39
|
+
"hover",
|
|
40
|
+
}
|
|
41
|
+
ASSERT_METHODS = {
|
|
42
|
+
"to_be_visible": "assert_visible",
|
|
43
|
+
"to_contain_text": "assert_text",
|
|
44
|
+
"to_have_text": "assert_exact_text",
|
|
45
|
+
"to_have_url": "assert_url",
|
|
46
|
+
"to_have_value": "assert_value",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _literal(node: ast.AST | None, default: Any = None) -> Any:
|
|
51
|
+
if node is None:
|
|
52
|
+
return default
|
|
53
|
+
try:
|
|
54
|
+
return ast.literal_eval(node)
|
|
55
|
+
except (ValueError, TypeError):
|
|
56
|
+
return default
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _kw_node(call: ast.Call, name: str) -> ast.AST | None:
|
|
60
|
+
for item in call.keywords:
|
|
61
|
+
if item.arg == name:
|
|
62
|
+
return item.value
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _valid_call_shape(call: ast.Call, *, positional: int, keywords: set[str] | None = None) -> bool:
|
|
67
|
+
allowed = keywords or set()
|
|
68
|
+
names = [item.arg for item in call.keywords]
|
|
69
|
+
return (
|
|
70
|
+
len(call.args) == positional
|
|
71
|
+
and all(name is not None and name in allowed for name in names)
|
|
72
|
+
and len(names) == len(set(names))
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _root_is_page(node: ast.AST) -> bool:
|
|
77
|
+
while isinstance(node, (ast.Call, ast.Attribute)):
|
|
78
|
+
node = node.func if isinstance(node, ast.Call) else node.value
|
|
79
|
+
return isinstance(node, ast.Name) and node.id == "page"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _selector(node: ast.AST) -> Selector | None:
|
|
83
|
+
if isinstance(node, ast.Name) and node.id == "page":
|
|
84
|
+
return Selector("page")
|
|
85
|
+
if isinstance(node, ast.Attribute) and node.attr == "first":
|
|
86
|
+
base = _selector(node.value)
|
|
87
|
+
if base:
|
|
88
|
+
return Selector(**{**base.__dict__, "modifiers": (*base.modifiers, "first")})
|
|
89
|
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
method = node.func.attr
|
|
93
|
+
if method == "nth":
|
|
94
|
+
if not _valid_call_shape(node, positional=1):
|
|
95
|
+
return None
|
|
96
|
+
base = _selector(node.func.value)
|
|
97
|
+
index = _literal(node.args[0], _MISSING) if node.args else _MISSING
|
|
98
|
+
if base and isinstance(index, int) and index >= 0:
|
|
99
|
+
return Selector(**{**base.__dict__, "modifiers": (*base.modifiers, f"nth:{index}")})
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
engine = SELECTOR_METHODS.get(method)
|
|
103
|
+
if not (engine and isinstance(node.func.value, ast.Name) and node.func.value.id == "page"):
|
|
104
|
+
return None
|
|
105
|
+
allowed_keywords = (
|
|
106
|
+
{"name", "exact"} if engine == "role" else ({"exact"} if engine != "css" else set())
|
|
107
|
+
)
|
|
108
|
+
if not _valid_call_shape(node, positional=1, keywords=allowed_keywords):
|
|
109
|
+
return None
|
|
110
|
+
exact_node = _kw_node(node, "exact")
|
|
111
|
+
exact = _literal(exact_node, _MISSING) if exact_node is not None else None
|
|
112
|
+
if exact is _MISSING or (exact is not None and not isinstance(exact, bool)):
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
if engine == "role":
|
|
116
|
+
role = _literal(node.args[0], _MISSING) if node.args else _MISSING
|
|
117
|
+
if not isinstance(role, str) or not role:
|
|
118
|
+
return None
|
|
119
|
+
name_node = _kw_node(node, "name")
|
|
120
|
+
name = _literal(name_node, _MISSING) if name_node is not None else None
|
|
121
|
+
if name is _MISSING or (name is not None and not isinstance(name, str)):
|
|
122
|
+
return None
|
|
123
|
+
return Selector(engine="role", role=role, name=name, exact=exact)
|
|
124
|
+
|
|
125
|
+
value = _literal(node.args[0], _MISSING) if node.args else _MISSING
|
|
126
|
+
if not isinstance(value, str) or not value:
|
|
127
|
+
return None
|
|
128
|
+
return Selector(engine=engine, value=value, exact=exact)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _call_arg(call: ast.Call, index: int = 0, default: Any = None) -> Any:
|
|
132
|
+
return _literal(call.args[index], default) if len(call.args) > index else default
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _portable_calls(tree: ast.Module) -> list[ast.Call]:
|
|
136
|
+
functions = [
|
|
137
|
+
node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
138
|
+
]
|
|
139
|
+
if len(functions) != 1 or isinstance(functions[0], ast.AsyncFunctionDef):
|
|
140
|
+
raise FlowValidationError("Recording must contain exactly one synchronous test function")
|
|
141
|
+
test_function = functions[0]
|
|
142
|
+
if not test_function.name.startswith("test"):
|
|
143
|
+
raise FlowValidationError("Recorded function name must start with `test`")
|
|
144
|
+
args = test_function.args
|
|
145
|
+
if (
|
|
146
|
+
test_function.decorator_list
|
|
147
|
+
or len(args.args) != 1
|
|
148
|
+
or args.args[0].arg != "page"
|
|
149
|
+
or args.posonlyargs
|
|
150
|
+
or args.kwonlyargs
|
|
151
|
+
or args.vararg
|
|
152
|
+
or args.kwarg
|
|
153
|
+
or args.defaults
|
|
154
|
+
or args.kw_defaults
|
|
155
|
+
):
|
|
156
|
+
raise FlowValidationError(
|
|
157
|
+
"Recorded test must be an undecorated function with exactly one `page` argument"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
permitted_module_nodes = (ast.Import, ast.ImportFrom, ast.FunctionDef)
|
|
161
|
+
for node in tree.body:
|
|
162
|
+
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
|
|
163
|
+
continue
|
|
164
|
+
if not isinstance(node, permitted_module_nodes):
|
|
165
|
+
raise FlowValidationError(
|
|
166
|
+
f"Unsupported module-level statement at line {getattr(node, 'lineno', '?')}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
calls: list[ast.Call] = []
|
|
170
|
+
for statement in test_function.body:
|
|
171
|
+
if isinstance(statement, ast.Pass):
|
|
172
|
+
continue
|
|
173
|
+
if not (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call)):
|
|
174
|
+
raise FlowValidationError(
|
|
175
|
+
"Control flow, assignments, context managers, and nested statements are not "
|
|
176
|
+
f"portable (line {getattr(statement, 'lineno', '?')})"
|
|
177
|
+
)
|
|
178
|
+
calls.append(statement.value)
|
|
179
|
+
return calls
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_codegen(
|
|
183
|
+
source: str,
|
|
184
|
+
*,
|
|
185
|
+
name: str,
|
|
186
|
+
intent: str = "Replay the demonstrated browser workflow reliably.",
|
|
187
|
+
success_criteria: str = "The recorded assertions pass.",
|
|
188
|
+
success_text: str | None = None,
|
|
189
|
+
redact_all_inputs: bool = True,
|
|
190
|
+
) -> Workflow:
|
|
191
|
+
try:
|
|
192
|
+
tree = ast.parse(source)
|
|
193
|
+
except SyntaxError as exc:
|
|
194
|
+
raise FlowValidationError(
|
|
195
|
+
f"Recording is not valid Python at line {exc.lineno}: {exc.msg}"
|
|
196
|
+
) from exc
|
|
197
|
+
|
|
198
|
+
actions: list[Action] = []
|
|
199
|
+
variables: list[str] = []
|
|
200
|
+
start_url = ""
|
|
201
|
+
value_index = 1
|
|
202
|
+
protected_literals: dict[str, str] = {}
|
|
203
|
+
|
|
204
|
+
def reject_captured_placeholder(value: str, line: int | None, context: str) -> None:
|
|
205
|
+
if ANY_PLACEHOLDER_SCAN_RE.search(value):
|
|
206
|
+
raise FlowValidationError(
|
|
207
|
+
f"Line {line}: captured placeholder syntax is not allowed in {context}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def protect_text(value: str) -> str:
|
|
211
|
+
protected = value
|
|
212
|
+
for literal, variable in sorted(
|
|
213
|
+
protected_literals.items(), key=lambda item: len(item[0]), reverse=True
|
|
214
|
+
):
|
|
215
|
+
if literal and literal in protected:
|
|
216
|
+
if len(literal) < 3 and protected != literal:
|
|
217
|
+
raise FlowValidationError(
|
|
218
|
+
"A protected value is too short to substitute safely inside selector text"
|
|
219
|
+
)
|
|
220
|
+
protected = protected.replace(literal, f"${{{variable}}}")
|
|
221
|
+
return protected
|
|
222
|
+
|
|
223
|
+
def protect_selector(selector: Selector, line: int | None) -> Selector:
|
|
224
|
+
fields = {
|
|
225
|
+
"value": selector.value,
|
|
226
|
+
"role": selector.role,
|
|
227
|
+
"name": selector.name,
|
|
228
|
+
}
|
|
229
|
+
for value in fields.values():
|
|
230
|
+
if value is not None:
|
|
231
|
+
reject_captured_placeholder(value, line, "selector")
|
|
232
|
+
return Selector(
|
|
233
|
+
engine=selector.engine,
|
|
234
|
+
value=protect_text(selector.value) if selector.value is not None else None,
|
|
235
|
+
role=protect_text(selector.role) if selector.role is not None else None,
|
|
236
|
+
name=protect_text(selector.name) if selector.name is not None else None,
|
|
237
|
+
exact=selector.exact,
|
|
238
|
+
modifiers=selector.modifiers,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def add_placeholders(value: Any) -> None:
|
|
242
|
+
if isinstance(value, str):
|
|
243
|
+
for variable in PLACEHOLDER_SCAN_RE.findall(value):
|
|
244
|
+
if variable not in variables:
|
|
245
|
+
variables.append(variable)
|
|
246
|
+
|
|
247
|
+
def fail(method: str, line: int | None, reason: str) -> None:
|
|
248
|
+
raise FlowValidationError(
|
|
249
|
+
f"Line {line}: {reason}; `{method}(...)` cannot be replayed safely"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
for call in _portable_calls(tree):
|
|
253
|
+
if not isinstance(call.func, ast.Attribute):
|
|
254
|
+
fail("call", getattr(call, "lineno", None), "non-Playwright call detected")
|
|
255
|
+
method = call.func.attr
|
|
256
|
+
line = getattr(call, "lineno", None)
|
|
257
|
+
|
|
258
|
+
expect_call = call.func.value
|
|
259
|
+
is_expect = (
|
|
260
|
+
isinstance(expect_call, ast.Call)
|
|
261
|
+
and isinstance(expect_call.func, ast.Name)
|
|
262
|
+
and expect_call.func.id == "expect"
|
|
263
|
+
and bool(expect_call.args)
|
|
264
|
+
)
|
|
265
|
+
if is_expect:
|
|
266
|
+
if method not in ASSERT_METHODS:
|
|
267
|
+
fail(method, line, "unsupported Playwright assertion")
|
|
268
|
+
if not _valid_call_shape(expect_call, positional=1):
|
|
269
|
+
fail(method, line, "unsupported expect(...) arguments")
|
|
270
|
+
expected_arguments = 0 if method == "to_be_visible" else 1
|
|
271
|
+
if not _valid_call_shape(call, positional=expected_arguments):
|
|
272
|
+
fail(method, line, "unsupported assertion arguments")
|
|
273
|
+
selector = _selector(expect_call.args[0])
|
|
274
|
+
if not selector:
|
|
275
|
+
fail(method, line, "assertion target is dynamic or not portable")
|
|
276
|
+
selector = protect_selector(selector, line)
|
|
277
|
+
kind = ASSERT_METHODS[method]
|
|
278
|
+
expected = _call_arg(call, default=_MISSING)
|
|
279
|
+
if kind == "assert_visible":
|
|
280
|
+
expected = True
|
|
281
|
+
elif expected is _MISSING:
|
|
282
|
+
fail(method, line, "assertion value is dynamic or missing")
|
|
283
|
+
if kind == "assert_url":
|
|
284
|
+
if not isinstance(expected, str):
|
|
285
|
+
fail(method, line, "URL assertion must use a literal string")
|
|
286
|
+
reject_captured_placeholder(expected, line, "URL assertion")
|
|
287
|
+
expected = redact_url(protect_text(expected))
|
|
288
|
+
add_placeholders(expected)
|
|
289
|
+
elif kind in {"assert_text", "assert_exact_text", "assert_value"}:
|
|
290
|
+
if not isinstance(expected, str):
|
|
291
|
+
fail(method, line, "text/value assertion must use a literal string")
|
|
292
|
+
reject_captured_placeholder(expected, line, "assertion value")
|
|
293
|
+
raw_expected = expected
|
|
294
|
+
expected, variable = sanitize_fill_value(
|
|
295
|
+
selector,
|
|
296
|
+
expected,
|
|
297
|
+
value_index,
|
|
298
|
+
redact_all_inputs=redact_all_inputs,
|
|
299
|
+
)
|
|
300
|
+
value_index += 1
|
|
301
|
+
if variable and variable not in variables:
|
|
302
|
+
variables.append(variable)
|
|
303
|
+
if variable:
|
|
304
|
+
protected_literals[raw_expected] = variable
|
|
305
|
+
actions.append(
|
|
306
|
+
Action(kind=kind, selector=selector, expected=expected, source_line=line)
|
|
307
|
+
)
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
if method not in ACTION_METHODS:
|
|
311
|
+
fail(method, line, "unsupported Playwright call")
|
|
312
|
+
expected_arguments = 1 if method in {"goto", "fill", "press", "select_option"} else 0
|
|
313
|
+
if not _valid_call_shape(call, positional=expected_arguments):
|
|
314
|
+
fail(method, line, "unsupported action arguments")
|
|
315
|
+
selector = _selector(call.func.value)
|
|
316
|
+
if selector is None:
|
|
317
|
+
fail(method, line, "action target is dynamic or not portable")
|
|
318
|
+
selector = protect_selector(selector, line)
|
|
319
|
+
|
|
320
|
+
if method == "goto":
|
|
321
|
+
raw_url = _call_arg(call, default=_MISSING)
|
|
322
|
+
if not isinstance(raw_url, str) or not raw_url:
|
|
323
|
+
fail(method, line, "navigation URL is dynamic or missing")
|
|
324
|
+
reject_captured_placeholder(raw_url, line, "navigation URL")
|
|
325
|
+
safe_url = redact_url(protect_text(raw_url))
|
|
326
|
+
if not start_url:
|
|
327
|
+
start_url = safe_url
|
|
328
|
+
add_placeholders(safe_url)
|
|
329
|
+
actions.append(
|
|
330
|
+
Action(
|
|
331
|
+
kind="goto",
|
|
332
|
+
selector=Selector("page"),
|
|
333
|
+
value=safe_url,
|
|
334
|
+
risk=classify_risk("goto", Selector("page"), safe_url),
|
|
335
|
+
source_line=line,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
value = _call_arg(call, default=_MISSING)
|
|
341
|
+
if method in {"fill", "press", "select_option"} and value is _MISSING:
|
|
342
|
+
fail(method, line, "action value is dynamic or missing")
|
|
343
|
+
if value is _MISSING:
|
|
344
|
+
value = None
|
|
345
|
+
if isinstance(value, str):
|
|
346
|
+
reject_captured_placeholder(value, line, "action value")
|
|
347
|
+
if method in {"fill", "select_option"}:
|
|
348
|
+
if not isinstance(value, str):
|
|
349
|
+
fail(method, line, "only literal string values are portable")
|
|
350
|
+
raw_value = value
|
|
351
|
+
value, variable = sanitize_fill_value(
|
|
352
|
+
selector,
|
|
353
|
+
value,
|
|
354
|
+
value_index,
|
|
355
|
+
redact_all_inputs=redact_all_inputs,
|
|
356
|
+
)
|
|
357
|
+
value_index += 1
|
|
358
|
+
if variable and variable not in variables:
|
|
359
|
+
variables.append(variable)
|
|
360
|
+
if variable:
|
|
361
|
+
protected_literals[raw_value] = variable
|
|
362
|
+
risk = classify_risk(method, selector, value)
|
|
363
|
+
actions.append(
|
|
364
|
+
Action(
|
|
365
|
+
kind=method,
|
|
366
|
+
selector=selector,
|
|
367
|
+
value=value,
|
|
368
|
+
risk=risk,
|
|
369
|
+
source_line=line,
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if success_text:
|
|
374
|
+
success_selector = protect_selector(Selector(engine="text", value=success_text), None)
|
|
375
|
+
actions.append(
|
|
376
|
+
Action(
|
|
377
|
+
kind="assert_visible",
|
|
378
|
+
selector=success_selector,
|
|
379
|
+
expected=True,
|
|
380
|
+
note="Success criterion supplied during compilation",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
if not actions:
|
|
384
|
+
raise FlowValidationError("No supported Playwright actions were found in the recording")
|
|
385
|
+
if not start_url:
|
|
386
|
+
raise FlowValidationError("A literal page.goto() URL is required for portable replay")
|
|
387
|
+
if not any(action.kind.startswith("assert_") for action in actions):
|
|
388
|
+
raise FlowValidationError(
|
|
389
|
+
"No executable assertion was captured; record an expect(...) assertion or supply "
|
|
390
|
+
"--success-text"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
workflow = Workflow(
|
|
394
|
+
name=name,
|
|
395
|
+
intent=intent.strip() or "Replay the demonstrated browser workflow reliably.",
|
|
396
|
+
start_url=start_url,
|
|
397
|
+
actions=actions,
|
|
398
|
+
success_criteria=success_criteria.strip() or "The recorded assertions pass.",
|
|
399
|
+
variables=variables,
|
|
400
|
+
)
|
|
401
|
+
workflow.validate()
|
|
402
|
+
return workflow
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def parse_codegen_file(path: str | Path, **kwargs: Any) -> Workflow:
|
|
406
|
+
source = Path(path).read_text(encoding="utf-8")
|
|
407
|
+
return parse_codegen(source, **kwargs)
|
flow2skill/recorder.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .exporter import write_bundle
|
|
15
|
+
from .model import FlowValidationError, slugify
|
|
16
|
+
from .parser import parse_codegen
|
|
17
|
+
|
|
18
|
+
DEFAULT_CODEGEN_VERSION = "1.61.0"
|
|
19
|
+
STALE_CAPTURE_SECONDS = 60 * 60
|
|
20
|
+
SAFE_OPTION_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def codegen_command(
|
|
24
|
+
*,
|
|
25
|
+
url: str,
|
|
26
|
+
raw_output: Path,
|
|
27
|
+
channel: str = "chrome",
|
|
28
|
+
test_id_attribute: str = "data-testid",
|
|
29
|
+
) -> list[str]:
|
|
30
|
+
if not re.match(r"^(https?|file)://", url, re.IGNORECASE):
|
|
31
|
+
raise FlowValidationError("Recorder URL must use http://, https://, or file://")
|
|
32
|
+
if not SAFE_OPTION_RE.fullmatch(channel):
|
|
33
|
+
raise FlowValidationError("Browser channel contains unsupported characters")
|
|
34
|
+
if not SAFE_OPTION_RE.fullmatch(test_id_attribute):
|
|
35
|
+
raise FlowValidationError("Test-id attribute contains unsupported characters")
|
|
36
|
+
version = os.getenv("FLOW2SKILL_CODEGEN_VERSION", DEFAULT_CODEGEN_VERSION)
|
|
37
|
+
if not re.fullmatch(r"\d+(?:\.\d+){1,3}", version):
|
|
38
|
+
raise FlowValidationError("FLOW2SKILL_CODEGEN_VERSION must be numeric")
|
|
39
|
+
args = [
|
|
40
|
+
"npx",
|
|
41
|
+
"--yes",
|
|
42
|
+
f"playwright@{version}",
|
|
43
|
+
"codegen",
|
|
44
|
+
"--target=python-pytest",
|
|
45
|
+
f"--output={raw_output}",
|
|
46
|
+
f"--channel={channel}",
|
|
47
|
+
f"--test-id-attribute={test_id_attribute}",
|
|
48
|
+
"--block-service-workers",
|
|
49
|
+
url,
|
|
50
|
+
]
|
|
51
|
+
if os.name == "nt":
|
|
52
|
+
node = shutil.which("node.exe")
|
|
53
|
+
npx_cmd = shutil.which("npx.cmd") or shutil.which("npx")
|
|
54
|
+
if not node or not npx_cmd:
|
|
55
|
+
raise FlowValidationError("Node.js and npx are required for recording")
|
|
56
|
+
npx_cli = Path(npx_cmd).resolve().parent / "node_modules" / "npm" / "bin" / "npx-cli.js"
|
|
57
|
+
if not npx_cli.is_file():
|
|
58
|
+
raise FlowValidationError(f"Could not locate the npx CLI beside {npx_cmd}")
|
|
59
|
+
return [node, str(npx_cli), *args[1:]]
|
|
60
|
+
npx = shutil.which("npx")
|
|
61
|
+
if not npx:
|
|
62
|
+
raise FlowValidationError("npx is required for recording but was not found on PATH")
|
|
63
|
+
args[0] = npx
|
|
64
|
+
return args
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compile_source(
|
|
68
|
+
source: str,
|
|
69
|
+
*,
|
|
70
|
+
name: str,
|
|
71
|
+
output_dir: str | Path,
|
|
72
|
+
intent: str = "Replay the demonstrated browser workflow reliably.",
|
|
73
|
+
success_criteria: str = "The recorded assertions pass.",
|
|
74
|
+
success_text: str | None = None,
|
|
75
|
+
redact_all_inputs: bool = True,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
workflow = parse_codegen(
|
|
78
|
+
source,
|
|
79
|
+
name=name,
|
|
80
|
+
intent=intent,
|
|
81
|
+
success_criteria=success_criteria,
|
|
82
|
+
success_text=success_text,
|
|
83
|
+
redact_all_inputs=redact_all_inputs,
|
|
84
|
+
)
|
|
85
|
+
paths = write_bundle(workflow, output_dir)
|
|
86
|
+
return {
|
|
87
|
+
"workflow": workflow,
|
|
88
|
+
"paths": paths,
|
|
89
|
+
"output_dir": Path(output_dir).resolve(),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class RecordingJob:
|
|
95
|
+
job_id: str
|
|
96
|
+
name: str
|
|
97
|
+
intent: str
|
|
98
|
+
success_criteria: str
|
|
99
|
+
success_text: str | None
|
|
100
|
+
output_dir: Path
|
|
101
|
+
raw_path: Path
|
|
102
|
+
log_path: Path
|
|
103
|
+
process: subprocess.Popen[Any]
|
|
104
|
+
redact_all_inputs: bool = True
|
|
105
|
+
state: str = "recording"
|
|
106
|
+
error: str | None = None
|
|
107
|
+
result: dict[str, Any] | None = None
|
|
108
|
+
_finalized: bool = field(default=False, repr=False)
|
|
109
|
+
|
|
110
|
+
def status(self) -> dict[str, Any]:
|
|
111
|
+
exit_code = self.process.poll()
|
|
112
|
+
if exit_code is None:
|
|
113
|
+
return {
|
|
114
|
+
"job_id": self.job_id,
|
|
115
|
+
"state": "recording",
|
|
116
|
+
"message": "Recorder is open. Complete the flow, then close the Playwright window.",
|
|
117
|
+
}
|
|
118
|
+
if not self._finalized:
|
|
119
|
+
self._finalize(exit_code)
|
|
120
|
+
response: dict[str, Any] = {"job_id": self.job_id, "state": self.state}
|
|
121
|
+
if self.error:
|
|
122
|
+
response["error"] = self.error
|
|
123
|
+
if self.result:
|
|
124
|
+
workflow = self.result["workflow"]
|
|
125
|
+
response.update(
|
|
126
|
+
{
|
|
127
|
+
"workspace": str(self.output_dir),
|
|
128
|
+
"workflow": workflow.to_dict(),
|
|
129
|
+
"files": {key: str(value) for key, value in self.result["paths"].items()},
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
return response
|
|
133
|
+
|
|
134
|
+
def _finalize(self, exit_code: int) -> None:
|
|
135
|
+
self._finalized = True
|
|
136
|
+
try:
|
|
137
|
+
if exit_code != 0:
|
|
138
|
+
raise FlowValidationError(
|
|
139
|
+
f"Playwright recorder exited with code {exit_code}; ephemeral logs were deleted"
|
|
140
|
+
)
|
|
141
|
+
if not self.raw_path.is_file() or self.raw_path.stat().st_size == 0:
|
|
142
|
+
raise FlowValidationError("Recorder closed without producing a workflow")
|
|
143
|
+
source = self.raw_path.read_text(encoding="utf-8")
|
|
144
|
+
self.result = compile_source(
|
|
145
|
+
source,
|
|
146
|
+
name=self.name,
|
|
147
|
+
output_dir=self.output_dir,
|
|
148
|
+
intent=self.intent,
|
|
149
|
+
success_criteria=self.success_criteria,
|
|
150
|
+
success_text=self.success_text,
|
|
151
|
+
redact_all_inputs=self.redact_all_inputs,
|
|
152
|
+
)
|
|
153
|
+
self.state = "complete"
|
|
154
|
+
except Exception as exc: # surfaced to the local Studio/API
|
|
155
|
+
self.state = "failed"
|
|
156
|
+
self.error = str(exc)
|
|
157
|
+
finally:
|
|
158
|
+
# Codegen can briefly contain literal inputs. Never keep the raw capture.
|
|
159
|
+
self.raw_path.unlink(missing_ok=True)
|
|
160
|
+
self.log_path.unlink(missing_ok=True)
|
|
161
|
+
|
|
162
|
+
def cancel(self) -> dict[str, Any]:
|
|
163
|
+
if self.process.poll() is None:
|
|
164
|
+
if os.name == "nt":
|
|
165
|
+
subprocess.run(
|
|
166
|
+
["taskkill.exe", "/PID", str(self.process.pid), "/T", "/F"],
|
|
167
|
+
stdout=subprocess.DEVNULL,
|
|
168
|
+
stderr=subprocess.DEVNULL,
|
|
169
|
+
check=False,
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
self.process.terminate()
|
|
173
|
+
try:
|
|
174
|
+
self.process.wait(timeout=5)
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
self.process.kill()
|
|
177
|
+
self._finalized = True
|
|
178
|
+
self.state = "cancelled"
|
|
179
|
+
self.raw_path.unlink(missing_ok=True)
|
|
180
|
+
self.log_path.unlink(missing_ok=True)
|
|
181
|
+
return {"job_id": self.job_id, "state": self.state}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class RecorderManager:
|
|
185
|
+
def __init__(self, workspace_root: str | Path):
|
|
186
|
+
self.workspace_root = Path(workspace_root).resolve()
|
|
187
|
+
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
self._jobs: dict[str, RecordingJob] = {}
|
|
189
|
+
self._lock = threading.Lock()
|
|
190
|
+
self._cleanup_stale_captures()
|
|
191
|
+
|
|
192
|
+
def _cleanup_stale_captures(self) -> None:
|
|
193
|
+
cutoff = time.time() - STALE_CAPTURE_SECONDS
|
|
194
|
+
for pattern in (".*.raw.py", ".*.recorder.log"):
|
|
195
|
+
for path in self.workspace_root.rglob(pattern):
|
|
196
|
+
try:
|
|
197
|
+
if path.is_file() and path.stat().st_mtime < cutoff:
|
|
198
|
+
path.unlink(missing_ok=True)
|
|
199
|
+
except OSError:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
def start(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
name: str,
|
|
206
|
+
url: str,
|
|
207
|
+
intent: str,
|
|
208
|
+
success_criteria: str,
|
|
209
|
+
success_text: str | None,
|
|
210
|
+
redact_all_inputs: bool = True,
|
|
211
|
+
channel: str = "chrome",
|
|
212
|
+
) -> RecordingJob:
|
|
213
|
+
if not url.lower().startswith(("http://", "https://", "file://")):
|
|
214
|
+
raise FlowValidationError("Recorder URL must use http://, https://, or file://")
|
|
215
|
+
workspace = self.workspace_root / slugify(name)
|
|
216
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
job_id = secrets.token_hex(8)
|
|
218
|
+
raw_path = workspace / f".{job_id}.raw.py"
|
|
219
|
+
log_path = workspace / f".{job_id}.recorder.log"
|
|
220
|
+
command = codegen_command(url=url, raw_output=raw_path, channel=channel)
|
|
221
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
|
222
|
+
log_handle = log_path.open("w", encoding="utf-8")
|
|
223
|
+
try:
|
|
224
|
+
process = subprocess.Popen(
|
|
225
|
+
command,
|
|
226
|
+
cwd=workspace,
|
|
227
|
+
stdout=log_handle,
|
|
228
|
+
stderr=subprocess.STDOUT,
|
|
229
|
+
text=True,
|
|
230
|
+
creationflags=creationflags,
|
|
231
|
+
)
|
|
232
|
+
except Exception:
|
|
233
|
+
log_handle.close()
|
|
234
|
+
raw_path.unlink(missing_ok=True)
|
|
235
|
+
log_path.unlink(missing_ok=True)
|
|
236
|
+
raise
|
|
237
|
+
finally:
|
|
238
|
+
log_handle.close()
|
|
239
|
+
job = RecordingJob(
|
|
240
|
+
job_id=job_id,
|
|
241
|
+
name=name,
|
|
242
|
+
intent=intent,
|
|
243
|
+
success_criteria=success_criteria,
|
|
244
|
+
success_text=success_text,
|
|
245
|
+
output_dir=workspace,
|
|
246
|
+
raw_path=raw_path,
|
|
247
|
+
log_path=log_path,
|
|
248
|
+
process=process,
|
|
249
|
+
redact_all_inputs=redact_all_inputs,
|
|
250
|
+
)
|
|
251
|
+
with self._lock:
|
|
252
|
+
self._jobs[job_id] = job
|
|
253
|
+
return job
|
|
254
|
+
|
|
255
|
+
def get(self, job_id: str) -> RecordingJob:
|
|
256
|
+
with self._lock:
|
|
257
|
+
job = self._jobs.get(job_id)
|
|
258
|
+
if not job:
|
|
259
|
+
raise FlowValidationError("Unknown recorder job")
|
|
260
|
+
return job
|
|
261
|
+
|
|
262
|
+
def cancel(self, job_id: str) -> dict[str, Any]:
|
|
263
|
+
return self.get(job_id).cancel()
|
|
264
|
+
|
|
265
|
+
def terminate_all(self) -> None:
|
|
266
|
+
with self._lock:
|
|
267
|
+
jobs = list(self._jobs.values())
|
|
268
|
+
for job in jobs:
|
|
269
|
+
job.cancel()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def record_blocking(
|
|
273
|
+
*,
|
|
274
|
+
name: str,
|
|
275
|
+
url: str,
|
|
276
|
+
output_root: str | Path,
|
|
277
|
+
intent: str,
|
|
278
|
+
success_criteria: str,
|
|
279
|
+
success_text: str | None = None,
|
|
280
|
+
redact_all_inputs: bool = True,
|
|
281
|
+
channel: str = "chrome",
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
manager = RecorderManager(output_root)
|
|
284
|
+
job = manager.start(
|
|
285
|
+
name=name,
|
|
286
|
+
url=url,
|
|
287
|
+
intent=intent,
|
|
288
|
+
success_criteria=success_criteria,
|
|
289
|
+
success_text=success_text,
|
|
290
|
+
redact_all_inputs=redact_all_inputs,
|
|
291
|
+
channel=channel,
|
|
292
|
+
)
|
|
293
|
+
print("Recorder opened. Complete the browser flow, then close the Playwright window.")
|
|
294
|
+
job.process.wait()
|
|
295
|
+
status = job.status()
|
|
296
|
+
if status["state"] != "complete":
|
|
297
|
+
raise FlowValidationError(status.get("error", "Recording failed"))
|
|
298
|
+
return job.result or {}
|