Harden JoinQuant browser smoke automation

This commit is contained in:
Yuxuan Yan
2026-07-04 19:27:48 +08:00
parent 5fada75d23
commit efa9d24c73
4 changed files with 206 additions and 19 deletions
+145 -18
View File
@@ -39,33 +39,104 @@ def _backtest_actions() -> list[dict[str, Any]]:
return [
{"type": "goto", "url": "{strategy_url}"},
{
"type": "paste_text_file",
"selector": "textarea, .ace_text-input, .cm-content, .CodeMirror textarea",
"type": "click",
"selector": "text=不再提示",
"timeout_ms": 5000,
"force": True,
"optional": True,
"description": "Dismiss JoinQuant first-run editor guide.",
},
{
"type": "click",
"selector": "text=确定",
"timeout_ms": 5000,
"force": True,
"optional": True,
"description": "Close JoinQuant first-run editor guide.",
},
{
"type": "click",
"selector": "text=跳过",
"timeout_ms": 5000,
"force": True,
"optional": True,
"description": "Skip JoinQuant first-run editor guide.",
},
{
"type": "evaluate",
"script": (
"() => {"
"document.querySelectorAll("
"'.introjs-overlay,.introjs-helperLayer,.introjs-tooltipReferenceLayer,"
".introjs-tooltip,.introjs-disableInteraction'"
").forEach((node) => node.remove());"
"document.body.classList.remove('introjs-noscroll');"
"}"
),
"optional": True,
"description": "Remove any remaining JoinQuant intro overlay.",
},
{
"type": "set_ace_editor_file",
"selector": ".ace_editor",
"path": "{wrapper_path}",
"description": "Paste generated wrapper strategy into the code editor.",
"description": "Set generated wrapper strategy in the Ace code editor.",
},
{
"type": "set_input_files",
"selector": "input[type=file]",
"paths": "{target_csvs}",
"description": "Upload all aligned daily target CSV files.",
"timeout_ms": 5000,
"optional": True,
},
{
"type": "fill",
"selector": "input[name=start_date], input[placeholder*=开始]",
"text": "{backtest_start_date}",
"optional": True,
},
{
"type": "fill",
"selector": "input[name=end_date], input[placeholder*=结束]",
"text": "{backtest_end_date}",
"type": "evaluate",
"script": (
"(arg) => {"
"const setValue = (selector, value) => {"
"const el = document.querySelector(selector);"
"if (!el) return false;"
"el.value = value;"
"el.setAttribute('value', value);"
"el.dispatchEvent(new Event('input', {bubbles: true}));"
"el.dispatchEvent(new Event('change', {bubbles: true}));"
"return true;"
"};"
"return {"
"start: setValue('#startTime', arg.start),"
"end: setValue('#endTime', arg.end),"
"capital: setValue('#daily_backtest_capital_base_box', arg.capital)"
"};"
"}"
),
"arg": {
"start": "{backtest_start_date}",
"end": "{backtest_end_date}",
"capital": "{booksize}",
},
"description": "Set JoinQuant backtest dates and starting capital.",
"optional": True,
},
{
"type": "click",
"selector": "text=/运行回测|开始回测|回测|Run Backtest|Run/i",
"selector": "#algo-save-button",
"timeout_ms": 5000,
"description": "Save the JoinQuant strategy code.",
"optional": True,
},
{
"type": "click",
"selector": ".bootstrap-dialog .btn-primary, .modal.in button:has-text(\"确定\")",
"timeout_ms": 5000,
"force": True,
"description": "Confirm any JoinQuant save dialog.",
"optional": True,
},
{"type": "wait_for_timeout", "timeout_ms": 2000},
{
"type": "click",
"selector": "#daily-new-backtest-button",
"description": "Start the JoinQuant backtest.",
},
{
@@ -78,18 +149,21 @@ def _backtest_actions() -> list[dict[str, Any]]:
"type": "download",
"selector": "text=/导出成交|下载成交|fills|trades/i",
"save_as": "{expected_joinquant_csvs.fills}",
"timeout_ms": 15000,
"optional": True,
},
{
"type": "download",
"selector": "text=/导出持仓|下载持仓|positions/i",
"save_as": "{expected_joinquant_csvs.positions}",
"timeout_ms": 15000,
"optional": True,
},
{
"type": "download",
"selector": "text=/导出收益|下载收益|pnl|收益/i",
"save_as": "{expected_joinquant_csvs.pnl}",
"timeout_ms": 15000,
"optional": True,
},
{"type": "screenshot", "path": "{run_artifact_dir}/final.png"},
@@ -237,8 +311,8 @@ def _manifest_context(manifest: dict[str, Any], artifact_dir: Path, config: dict
return context
_TOKEN_RE = re.compile(r"^\{([^{}]+)\}$")
_PARTIAL_TOKEN_RE = re.compile(r"\{([^{}]+)\}")
_TOKEN_RE = re.compile(r"^\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}$")
_PARTIAL_TOKEN_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}")
def resolve_template(value: Any, context: dict[str, Any]) -> Any:
@@ -408,7 +482,10 @@ def _run_action(page: Any, action: dict[str, Any], context: dict[str, Any], time
if kind == "goto":
page.goto(str(resolved["url"]), wait_until=resolved.get("wait_until", "domcontentloaded"), timeout=timeout_ms)
elif kind == "click":
_locator(page, str(resolved["selector"])).click(timeout=timeout_ms)
_locator(page, str(resolved["selector"])).click(
timeout=timeout_ms,
force=bool(resolved.get("force", False)),
)
elif kind == "fill":
_locator(page, str(resolved["selector"])).fill(str(resolved.get("text", "")), timeout=timeout_ms)
elif kind == "press":
@@ -426,6 +503,29 @@ def _run_action(page: Any, action: dict[str, Any], context: dict[str, Any], time
page.keyboard.press("Control+A")
page.keyboard.insert_text(text)
record["n_chars"] = len(text)
elif kind == "set_ace_editor_file":
text = Path(str(resolved["path"])).read_text(encoding="utf-8")
selector = str(resolved.get("selector", ".ace_editor"))
page.wait_for_function(
"(selector) => Boolean(window.ace && document.querySelector(selector))",
arg=selector,
timeout=timeout_ms,
)
page.evaluate(
"""(arg) => {
const node = document.querySelector(arg.selector);
if (!node || !window.ace) {
throw new Error(`Ace editor not found for ${arg.selector}`);
}
const editor = window.ace.edit(node);
editor.setValue(arg.text, -1);
editor.clearSelection();
editor.focus();
return editor.getValue().length;
}""",
{"selector": selector, "text": text},
)
record["n_chars"] = len(text)
elif kind == "evaluate":
page.evaluate(str(resolved["script"]), resolved.get("arg"))
elif kind == "wait_for_selector":
@@ -479,6 +579,8 @@ def run_browser_flow(
raise ValueError("Browser config contains no actions")
records: list[dict[str, Any]] = []
failure: Exception | None = None
failure_artifacts: dict[str, str] = {}
with sync_playwright() as pw:
browser = pw.chromium.launch(
headless=bool(config.get("headless", False) if headless is None else headless)
@@ -489,8 +591,28 @@ def run_browser_flow(
)
page = context.new_page()
page.set_default_timeout(timeout_ms)
for action in actions:
records.append(_run_action(page, action, context_vars, timeout_ms))
try:
for action in actions:
records.append(_run_action(page, action, context_vars, timeout_ms))
except Exception as exc: # pragma: no cover - requires live browser UI.
failure = exc
screenshot_path = artifact_dir / "failure.png"
html_path = artifact_dir / "failure.html"
try:
page.screenshot(path=str(screenshot_path), full_page=True)
html_path.write_text(page.content(), encoding="utf-8")
failure_artifacts = {
"screenshot": str(screenshot_path),
"html": str(html_path),
}
except Exception as artifact_exc:
failure_artifacts = {"artifact_error": str(artifact_exc)}
records.append({
"type": "browser_flow",
"status": "failed",
"error": str(exc),
**failure_artifacts,
})
browser.close()
expected = manifest.get("expected_joinquant_csvs", {})
@@ -526,6 +648,7 @@ def run_browser_flow(
"manifest_path": str(manifest_path),
"config_path": str(config_path),
"flow": flow,
"status": "failed" if failure else "ok",
"storage_state": str(storage_state),
"artifact_dir": str(artifact_dir),
"actions": records,
@@ -538,6 +661,10 @@ def run_browser_flow(
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
if failure is not None:
raise RuntimeError(
f"Browser flow failed; report saved to {report_path}: {failure}"
) from failure
return report