@suiflex/suitest-mcp 0.1.0
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.
- package/LICENSE +201 -0
- package/README.md +77 -0
- package/bin/suitest-mcp.js +123 -0
- package/package.json +50 -0
- package/python/suitest_lifecycle/__init__.py +3 -0
- package/python/suitest_lifecycle/analyzers/__init__.py +1 -0
- package/python/suitest_lifecycle/analyzers/crawl.py +187 -0
- package/python/suitest_lifecycle/analyzers/express.py +226 -0
- package/python/suitest_lifecycle/analyzers/openapi.py +163 -0
- package/python/suitest_lifecycle/analyzers/postman.py +132 -0
- package/python/suitest_lifecycle/analyzers/react.py +107 -0
- package/python/suitest_lifecycle/analyzers/zod_schema.py +131 -0
- package/python/suitest_lifecycle/blackbox/__init__.py +11 -0
- package/python/suitest_lifecycle/blackbox/bootstrap.py +249 -0
- package/python/suitest_lifecycle/blackbox/crawler.py +383 -0
- package/python/suitest_lifecycle/blackbox/detector.py +169 -0
- package/python/suitest_lifecycle/blackbox/generator.py +608 -0
- package/python/suitest_lifecycle/blackbox/graph.py +107 -0
- package/python/suitest_lifecycle/blackbox/mcp.py +546 -0
- package/python/suitest_lifecycle/blackbox/models.py +299 -0
- package/python/suitest_lifecycle/blackbox/prd_ingest.py +108 -0
- package/python/suitest_lifecycle/blackbox/reporter.py +76 -0
- package/python/suitest_lifecycle/blackbox/selector.py +111 -0
- package/python/suitest_lifecycle/cli.py +127 -0
- package/python/suitest_lifecycle/config.py +314 -0
- package/python/suitest_lifecycle/enrich.py +140 -0
- package/python/suitest_lifecycle/exporters/__init__.py +1 -0
- package/python/suitest_lifecycle/exporters/backend.py +345 -0
- package/python/suitest_lifecycle/exporters/frontend.py +459 -0
- package/python/suitest_lifecycle/frontend_runtime.py +77 -0
- package/python/suitest_lifecycle/llm_bridge.py +365 -0
- package/python/suitest_lifecycle/mcp_server.py +187 -0
- package/python/suitest_lifecycle/models.py +166 -0
- package/python/suitest_lifecycle/orchestrator.py +500 -0
- package/python/suitest_lifecycle/paths.py +90 -0
- package/python/suitest_lifecycle/plan.py +366 -0
- package/python/suitest_lifecycle/plan_frontend.py +252 -0
- package/python/suitest_lifecycle/prd.py +92 -0
- package/python/suitest_lifecycle/process.py +111 -0
- package/python/suitest_lifecycle/publish.py +218 -0
- package/python/suitest_lifecycle/readiness.py +83 -0
- package/python/suitest_lifecycle/report.py +179 -0
- package/python/suitest_lifecycle/runner.py +138 -0
- package/python/suitest_lifecycle/serialize.py +131 -0
- package/python/suitest_lifecycle/tcm.py +149 -0
- package/python/suitest_lifecycle/tools.py +217 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Minimal Zod schema reader — extracts create-payload field shapes.
|
|
2
|
+
|
|
3
|
+
Lets the backend exporter synthesise *valid* request bodies (so generated CRUD
|
|
4
|
+
tests actually pass) without an LLM. Parses ``export const xSchema = z.object({…})``
|
|
5
|
+
blocks and reads each field's base type + ``optional``/``int``/``min`` markers.
|
|
6
|
+
Heuristic and intentionally conservative: unknown constructs are skipped.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_SCHEMA_RE = re.compile(r"""export\s+const\s+(?P<name>\w+)\s*=\s*z\.object\(\{""")
|
|
19
|
+
# A field starts as `<name>: z` — the base type may be on the next line
|
|
20
|
+
# (`stock: z\n .number()`), so we only anchor on `z` here and read the type
|
|
21
|
+
# from the field's segment.
|
|
22
|
+
_FIELD_START_RE = re.compile(r"""(?P<name>\b\w+)\s*:\s*z\b""")
|
|
23
|
+
_BASE_TYPE_RE = re.compile(
|
|
24
|
+
r"""\.\s*(?P<type>string|number|boolean|date|array|enum|object|coerce)\b"""
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ZodField:
|
|
30
|
+
name: str
|
|
31
|
+
base_type: str # string | number | boolean | ...
|
|
32
|
+
required: bool
|
|
33
|
+
is_int: bool
|
|
34
|
+
min_value: float | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _extract_object_body(text: str, open_index: int) -> str:
|
|
38
|
+
"""Return the substring inside the ``z.object({`` … ``})`` starting at open_index."""
|
|
39
|
+
depth = 0
|
|
40
|
+
i = open_index
|
|
41
|
+
start = -1
|
|
42
|
+
while i < len(text):
|
|
43
|
+
ch = text[i]
|
|
44
|
+
if ch == "{":
|
|
45
|
+
depth += 1
|
|
46
|
+
if depth == 1:
|
|
47
|
+
start = i + 1
|
|
48
|
+
elif ch == "}":
|
|
49
|
+
depth -= 1
|
|
50
|
+
if depth == 0:
|
|
51
|
+
return text[start:i]
|
|
52
|
+
i += 1
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_fields(body: str) -> list[ZodField]:
|
|
57
|
+
starts = list(_FIELD_START_RE.finditer(body))
|
|
58
|
+
fields: list[ZodField] = []
|
|
59
|
+
for idx, m in enumerate(starts):
|
|
60
|
+
seg_end = starts[idx + 1].start() if idx + 1 < len(starts) else len(body)
|
|
61
|
+
segment = body[m.start() : seg_end]
|
|
62
|
+
name = m.group("name")
|
|
63
|
+
type_match = _BASE_TYPE_RE.search(segment)
|
|
64
|
+
if type_match is None:
|
|
65
|
+
continue # not a real field (e.g. a nested option object)
|
|
66
|
+
base = type_match.group("type")
|
|
67
|
+
if base == "coerce":
|
|
68
|
+
inner = _BASE_TYPE_RE.search(segment[type_match.end() :])
|
|
69
|
+
base = inner.group("type") if inner else "number"
|
|
70
|
+
required = ".optional(" not in segment and ".nullable(" not in segment
|
|
71
|
+
is_int = ".int(" in segment
|
|
72
|
+
min_value: float | None = None
|
|
73
|
+
mm = re.search(r"\.min\(\s*([0-9]+(?:\.[0-9]+)?)", segment)
|
|
74
|
+
if mm:
|
|
75
|
+
min_value = float(mm.group(1))
|
|
76
|
+
fields.append(
|
|
77
|
+
ZodField(
|
|
78
|
+
name=name, base_type=base, required=required, is_int=is_int, min_value=min_value
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return fields
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def find_create_schema(project_path: Path, resource: str) -> list[ZodField]:
|
|
85
|
+
"""Find the create-payload fields for ``resource`` (e.g. 'products').
|
|
86
|
+
|
|
87
|
+
Looks for a schema whose name contains 'create' and the singular/plural of
|
|
88
|
+
the resource (``createProductSchema`` for resource ``products``). Falls back
|
|
89
|
+
to the first ``create*Schema`` found.
|
|
90
|
+
"""
|
|
91
|
+
src = project_path / "src"
|
|
92
|
+
if not src.is_dir():
|
|
93
|
+
src = project_path
|
|
94
|
+
singular = resource[:-1] if resource.endswith("s") else resource
|
|
95
|
+
best: list[ZodField] | None = None
|
|
96
|
+
fallback: list[ZodField] | None = None
|
|
97
|
+
|
|
98
|
+
for f in sorted(src.rglob("*.ts")):
|
|
99
|
+
if ".d.ts" in f.name:
|
|
100
|
+
continue
|
|
101
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
102
|
+
for m in _SCHEMA_RE.finditer(text):
|
|
103
|
+
name = m.group("name")
|
|
104
|
+
body = _extract_object_body(text, m.end() - 1)
|
|
105
|
+
fields = _parse_fields(body)
|
|
106
|
+
if not fields:
|
|
107
|
+
continue
|
|
108
|
+
lname = name.lower()
|
|
109
|
+
if "create" in lname and (singular in lname or resource in lname):
|
|
110
|
+
best = fields
|
|
111
|
+
elif "create" in lname and fallback is None:
|
|
112
|
+
fallback = fields
|
|
113
|
+
return best if best is not None else (fallback or [])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sample_value(field: ZodField, unique_token: str) -> object:
|
|
117
|
+
"""Produce a valid Python value for a field (used to build request bodies)."""
|
|
118
|
+
if field.base_type == "string":
|
|
119
|
+
if field.name.lower() == "sku":
|
|
120
|
+
return f"SKU-{unique_token}"
|
|
121
|
+
if field.name.lower() in {"email"}:
|
|
122
|
+
return f"user_{unique_token}@example.com"
|
|
123
|
+
min_len = int(field.min_value or 0)
|
|
124
|
+
base = f"Suitest {field.name.title()}"
|
|
125
|
+
return base if len(base) >= min_len else base + "x" * (min_len - len(base))
|
|
126
|
+
if field.base_type == "number":
|
|
127
|
+
base_num = field.min_value if field.min_value is not None else 1
|
|
128
|
+
return int(base_num) + 9 if field.is_int else float(base_num) + 9.99
|
|
129
|
+
if field.base_type == "boolean":
|
|
130
|
+
return True
|
|
131
|
+
return f"val_{unique_token}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Blackbox DOM Discovery & Testing Engine (ZERO tier, no repo, no LLM).
|
|
2
|
+
|
|
3
|
+
Shared core used by three consumers (do not fork logic into any single one):
|
|
4
|
+
|
|
5
|
+
* **Zero** — deterministic end-to-end: discover → generate → run → evidence.
|
|
6
|
+
* **MCP** — each stage is exposed as a ``blackbox_*`` tool for IDE agents.
|
|
7
|
+
* **LLM** — the serialized discovery/graph JSON is handed to models as context.
|
|
8
|
+
|
|
9
|
+
Import modules directly (``from suitest_lifecycle.blackbox.crawler import …``);
|
|
10
|
+
this package intentionally re-exports nothing (no barrel imports).
|
|
11
|
+
"""
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Bootstrap wizard — the TestSprite-style "prompt → browser form" flow.
|
|
2
|
+
|
|
3
|
+
The ``bootstrap_project`` MCP tool spins up a tiny stdlib web server on a
|
|
4
|
+
random localhost port, opens the user's browser at a one-page setup form
|
|
5
|
+
(target URL, credentials, crawl scope, optional **markdown PRD upload**),
|
|
6
|
+
writes ``suitest.config.json`` (+ ``PRD.md``) into the project directory, then
|
|
7
|
+
shuts itself down and returns the config path to the agent — which continues
|
|
8
|
+
the pipeline (discover → generate → run → report) unattended.
|
|
9
|
+
|
|
10
|
+
Stdlib only: http.server + email.parser for the multipart form.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import webbrowser
|
|
18
|
+
from email.parser import BytesParser
|
|
19
|
+
from email.policy import HTTP
|
|
20
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
_FORM_HTML = """<!doctype html>
|
|
25
|
+
<html><head><meta charset="utf-8"><title>Suitest — Project Setup</title>
|
|
26
|
+
<style>
|
|
27
|
+
:root {{ --bg:#0a0a0a; --card:#111; --line:#262626; --fg:#fafafa; --mut:#a3a3a3;
|
|
28
|
+
--dim:#737373; --acc:#4ade80; }}
|
|
29
|
+
* {{ box-sizing:border-box; }}
|
|
30
|
+
body {{ margin:0; background:var(--bg); color:var(--fg);
|
|
31
|
+
font:14px/1.5 -apple-system,'Segoe UI',Roboto,sans-serif; }}
|
|
32
|
+
.wrap {{ max-width:640px; margin:48px auto; padding:0 20px; }}
|
|
33
|
+
.logo {{ font-weight:700; font-size:18px; letter-spacing:-.02em; }}
|
|
34
|
+
.logo span {{ color:var(--acc); }}
|
|
35
|
+
h1 {{ font-size:22px; letter-spacing:-.01em; margin:24px 0 4px; }}
|
|
36
|
+
p.sub {{ color:var(--mut); margin:0 0 24px; font-size:13px; }}
|
|
37
|
+
.card {{ background:var(--card); border:1px solid var(--line); border-radius:10px;
|
|
38
|
+
padding:20px; margin-bottom:16px; }}
|
|
39
|
+
.card h2 {{ font-size:13px; margin:0 0 12px; color:var(--fg); }}
|
|
40
|
+
label {{ display:block; font-size:12px; color:var(--mut); margin:12px 0 4px; }}
|
|
41
|
+
input[type=text],input[type=password],input[type=number] {{ width:100%; padding:9px 11px;
|
|
42
|
+
background:var(--bg); border:1px solid var(--line); border-radius:7px; color:var(--fg);
|
|
43
|
+
font-size:13px; outline:none; }}
|
|
44
|
+
input:focus {{ border-color:var(--acc); }}
|
|
45
|
+
.row {{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }}
|
|
46
|
+
.check {{ display:flex; gap:8px; align-items:center; margin-top:12px; font-size:13px;
|
|
47
|
+
color:var(--mut); }}
|
|
48
|
+
.check input {{ accent-color:var(--acc); }}
|
|
49
|
+
.drop {{ border:1px solid var(--line); border-radius:8px; padding:18px; text-align:center;
|
|
50
|
+
color:var(--dim); font-size:12.5px; background:var(--bg); }}
|
|
51
|
+
button {{ width:100%; margin-top:8px; padding:12px; background:var(--acc); color:#052e12;
|
|
52
|
+
font-weight:600; font-size:14px; border:0; border-radius:8px; cursor:pointer; }}
|
|
53
|
+
button:hover {{ opacity:.9; }}
|
|
54
|
+
.hint {{ color:var(--dim); font-size:11.5px; margin-top:4px; }}
|
|
55
|
+
</style></head>
|
|
56
|
+
<body><div class="wrap">
|
|
57
|
+
<div class="logo">sui<span>test</span></div>
|
|
58
|
+
<h1>Project setup</h1>
|
|
59
|
+
<p class="sub">Blackbox UI testing (ZERO tier — deterministic, no LLM key needed).
|
|
60
|
+
Project: <code>{project}</code></p>
|
|
61
|
+
<form method="post" action="/submit" enctype="multipart/form-data">
|
|
62
|
+
<div class="card">
|
|
63
|
+
<h2>Target</h2>
|
|
64
|
+
<label>Application URL *</label>
|
|
65
|
+
<input type="text" name="targetUrl" placeholder="http://localhost:3000" required>
|
|
66
|
+
<div class="row">
|
|
67
|
+
<div><label>Login path</label>
|
|
68
|
+
<input type="text" name="loginUrl" value="/login"></div>
|
|
69
|
+
<div><label>Output directory</label>
|
|
70
|
+
<input type="text" name="output" value="suitest-output"></div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="card">
|
|
74
|
+
<h2>Test credentials (optional — leave empty for public apps)</h2>
|
|
75
|
+
<div class="row">
|
|
76
|
+
<div><label>Username / email</label>
|
|
77
|
+
<input type="text" name="username" placeholder="qa@example.com"></div>
|
|
78
|
+
<div><label>Password</label>
|
|
79
|
+
<input type="password" name="password"></div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="card">
|
|
83
|
+
<h2>Crawl scope</h2>
|
|
84
|
+
<div class="row">
|
|
85
|
+
<div><label>Max routes</label><input type="number" name="maxRoutes" value="30"></div>
|
|
86
|
+
<div><label>Max depth</label><input type="number" name="maxDepth" value="3"></div>
|
|
87
|
+
</div>
|
|
88
|
+
<label>Exclude paths (comma separated)</label>
|
|
89
|
+
<input type="text" name="exclude" value="/logout, /billing, /payment">
|
|
90
|
+
<div class="check"><input type="checkbox" name="safeMode" checked>
|
|
91
|
+
Safe mode — never click destructive actions (recommended)</div>
|
|
92
|
+
<div class="check"><input type="checkbox" name="allowMutation">
|
|
93
|
+
Allow mutating form submits (only with a resettable test database)</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="card">
|
|
96
|
+
<h2>Product spec (optional)</h2>
|
|
97
|
+
<div class="drop">
|
|
98
|
+
<input type="file" name="prd" accept=".md,.markdown"><br>
|
|
99
|
+
Markdown PRD — with a workspace LLM configured, the plan becomes
|
|
100
|
+
requirement-driven (TestSprite-style)
|
|
101
|
+
</div>
|
|
102
|
+
<div class="hint">Without a PRD the deterministic baseline suite is generated.</div>
|
|
103
|
+
</div>
|
|
104
|
+
<button type="submit">Save & continue in your IDE</button>
|
|
105
|
+
</form>
|
|
106
|
+
</div></body></html>"""
|
|
107
|
+
|
|
108
|
+
_DONE_HTML = """<!doctype html><html><head><meta charset="utf-8">
|
|
109
|
+
<style>body{background:#0a0a0a;color:#fafafa;font:15px -apple-system,sans-serif;
|
|
110
|
+
display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
111
|
+
.b{text-align:center}.t{color:#4ade80;font-size:40px}</style></head>
|
|
112
|
+
<body><div class="b"><div class="t">✓</div>
|
|
113
|
+
<h2>Configuration saved</h2><p style="color:#a3a3a3">You can close this tab —
|
|
114
|
+
your IDE agent is continuing with discovery → tests → report.</p>
|
|
115
|
+
</div></body></html>"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _State:
|
|
119
|
+
def __init__(self, project: Path) -> None:
|
|
120
|
+
self.project = project
|
|
121
|
+
self.done = threading.Event()
|
|
122
|
+
self.result: dict[str, Any] = {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_multipart(ctype: str, body: bytes) -> dict[str, tuple[str, bytes]]:
|
|
126
|
+
"""Return ``{field: (filename, value_bytes)}`` from a multipart POST."""
|
|
127
|
+
msg = BytesParser(policy=HTTP).parsebytes(
|
|
128
|
+
b"Content-Type: " + ctype.encode() + b"\r\n\r\n" + body
|
|
129
|
+
)
|
|
130
|
+
out: dict[str, tuple[str, bytes]] = {}
|
|
131
|
+
for part in msg.iter_parts():
|
|
132
|
+
name = part.get_param("name", header="content-disposition")
|
|
133
|
+
if not name:
|
|
134
|
+
continue
|
|
135
|
+
filename = part.get_filename() or ""
|
|
136
|
+
payload = part.get_payload(decode=True) or b""
|
|
137
|
+
out[str(name)] = (filename, payload)
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_config(fields: dict[str, tuple[str, bytes]], project: Path) -> dict[str, Any]:
|
|
142
|
+
def val(key: str, default: str = "") -> str:
|
|
143
|
+
return fields.get(key, ("", b""))[1].decode("utf-8", "replace").strip() or default
|
|
144
|
+
|
|
145
|
+
target = val("targetUrl").rstrip("/")
|
|
146
|
+
prd_name, prd_bytes = fields.get("prd", ("", b""))
|
|
147
|
+
prd_rel = ""
|
|
148
|
+
if prd_name and prd_bytes.strip():
|
|
149
|
+
prd_rel = "PRD.md"
|
|
150
|
+
(project / prd_rel).write_bytes(prd_bytes)
|
|
151
|
+
|
|
152
|
+
config: dict[str, Any] = {
|
|
153
|
+
"mode": "frontend",
|
|
154
|
+
"projectName": project.name or "blackbox-project",
|
|
155
|
+
"baseUrl": target,
|
|
156
|
+
"output": val("output", "suitest-output"),
|
|
157
|
+
"server": {"autostart": False},
|
|
158
|
+
# Publishing is mandatory in the blackbox pipeline — the run stage
|
|
159
|
+
# pushes into the Suitest TCM whenever the MCP server has credentials.
|
|
160
|
+
"publish": {"enabled": True},
|
|
161
|
+
"ui": {
|
|
162
|
+
"mode": "blackbox",
|
|
163
|
+
"targetUrl": target,
|
|
164
|
+
"auth": {
|
|
165
|
+
"strategy": "form",
|
|
166
|
+
"loginUrl": val("loginUrl", "/login"),
|
|
167
|
+
"username": val("username"),
|
|
168
|
+
"password": val("password"),
|
|
169
|
+
},
|
|
170
|
+
"crawl": {
|
|
171
|
+
"maxDepth": int(val("maxDepth", "3") or 3),
|
|
172
|
+
"maxRoutes": int(val("maxRoutes", "30") or 30),
|
|
173
|
+
"exclude": [x.strip() for x in val("exclude").split(",") if x.strip()],
|
|
174
|
+
"safeMode": "safeMode" in fields,
|
|
175
|
+
},
|
|
176
|
+
"testGeneration": {"allowMutation": "allowMutation" in fields},
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
if prd_rel:
|
|
180
|
+
config["prdFile"] = prd_rel
|
|
181
|
+
return config
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _make_handler(state: _State) -> type[BaseHTTPRequestHandler]:
|
|
185
|
+
class Handler(BaseHTTPRequestHandler):
|
|
186
|
+
def log_message(self, *args: object) -> None: # keep MCP stdio clean
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def _send(self, html: str, code: int = 200) -> None:
|
|
190
|
+
data = html.encode()
|
|
191
|
+
self.send_response(code)
|
|
192
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
193
|
+
self.send_header("Content-Length", str(len(data)))
|
|
194
|
+
self.end_headers()
|
|
195
|
+
self.wfile.write(data)
|
|
196
|
+
|
|
197
|
+
def do_GET(self) -> None:
|
|
198
|
+
self._send(_FORM_HTML.format(project=state.project))
|
|
199
|
+
|
|
200
|
+
def do_POST(self) -> None:
|
|
201
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
202
|
+
fields = _parse_multipart(self.headers.get("Content-Type", ""), self.rfile.read(length))
|
|
203
|
+
config = _build_config(fields, state.project)
|
|
204
|
+
config_path = state.project / "suitest.config.json"
|
|
205
|
+
config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
206
|
+
state.result = {
|
|
207
|
+
"configPath": str(config_path),
|
|
208
|
+
"targetUrl": config["baseUrl"],
|
|
209
|
+
"prdFile": config.get("prdFile", ""),
|
|
210
|
+
"safeMode": config["ui"]["crawl"]["safeMode"],
|
|
211
|
+
}
|
|
212
|
+
self._send(_DONE_HTML)
|
|
213
|
+
state.done.set()
|
|
214
|
+
|
|
215
|
+
return Handler
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_bootstrap_wizard(
|
|
219
|
+
project_path: str | Path = ".",
|
|
220
|
+
*,
|
|
221
|
+
open_browser: bool = True,
|
|
222
|
+
timeout_sec: int = 600,
|
|
223
|
+
on_ready: Any = None,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Serve the setup form; block until submitted (or timeout). Returns
|
|
226
|
+
``{configPath, targetUrl, prdFile, safeMode, url}``; empty dict on timeout.
|
|
227
|
+
``on_ready(url)`` is invoked once the server is listening (tests hook this).
|
|
228
|
+
"""
|
|
229
|
+
project = Path(project_path).resolve()
|
|
230
|
+
project.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
state = _State(project)
|
|
232
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(state))
|
|
233
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
234
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
235
|
+
thread.start()
|
|
236
|
+
try:
|
|
237
|
+
if on_ready is not None:
|
|
238
|
+
on_ready(url)
|
|
239
|
+
if open_browser:
|
|
240
|
+
webbrowser.open(url)
|
|
241
|
+
finished = state.done.wait(timeout=timeout_sec)
|
|
242
|
+
finally:
|
|
243
|
+
server.shutdown()
|
|
244
|
+
if not finished:
|
|
245
|
+
return {}
|
|
246
|
+
return {**state.result, "url": url}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
__all__ = ["run_bootstrap_wizard"]
|