Harden JoinQuant browser smoke automation
This commit is contained in:
+145
-18
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user