Harden JoinQuant browser smoke automation
This commit is contained in:
+142
-15
@@ -39,33 +39,104 @@ def _backtest_actions() -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{"type": "goto", "url": "{strategy_url}"},
|
{"type": "goto", "url": "{strategy_url}"},
|
||||||
{
|
{
|
||||||
"type": "paste_text_file",
|
"type": "click",
|
||||||
"selector": "textarea, .ace_text-input, .cm-content, .CodeMirror textarea",
|
"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}",
|
"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",
|
"type": "set_input_files",
|
||||||
"selector": "input[type=file]",
|
"selector": "input[type=file]",
|
||||||
"paths": "{target_csvs}",
|
"paths": "{target_csvs}",
|
||||||
"description": "Upload all aligned daily target CSV files.",
|
"description": "Upload all aligned daily target CSV files.",
|
||||||
|
"timeout_ms": 5000,
|
||||||
"optional": True,
|
"optional": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "fill",
|
"type": "evaluate",
|
||||||
"selector": "input[name=start_date], input[placeholder*=开始]",
|
"script": (
|
||||||
"text": "{backtest_start_date}",
|
"(arg) => {"
|
||||||
"optional": True,
|
"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.",
|
||||||
"type": "fill",
|
|
||||||
"selector": "input[name=end_date], input[placeholder*=结束]",
|
|
||||||
"text": "{backtest_end_date}",
|
|
||||||
"optional": True,
|
"optional": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "click",
|
"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.",
|
"description": "Start the JoinQuant backtest.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,18 +149,21 @@ def _backtest_actions() -> list[dict[str, Any]]:
|
|||||||
"type": "download",
|
"type": "download",
|
||||||
"selector": "text=/导出成交|下载成交|fills|trades/i",
|
"selector": "text=/导出成交|下载成交|fills|trades/i",
|
||||||
"save_as": "{expected_joinquant_csvs.fills}",
|
"save_as": "{expected_joinquant_csvs.fills}",
|
||||||
|
"timeout_ms": 15000,
|
||||||
"optional": True,
|
"optional": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "download",
|
"type": "download",
|
||||||
"selector": "text=/导出持仓|下载持仓|positions/i",
|
"selector": "text=/导出持仓|下载持仓|positions/i",
|
||||||
"save_as": "{expected_joinquant_csvs.positions}",
|
"save_as": "{expected_joinquant_csvs.positions}",
|
||||||
|
"timeout_ms": 15000,
|
||||||
"optional": True,
|
"optional": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "download",
|
"type": "download",
|
||||||
"selector": "text=/导出收益|下载收益|pnl|收益/i",
|
"selector": "text=/导出收益|下载收益|pnl|收益/i",
|
||||||
"save_as": "{expected_joinquant_csvs.pnl}",
|
"save_as": "{expected_joinquant_csvs.pnl}",
|
||||||
|
"timeout_ms": 15000,
|
||||||
"optional": True,
|
"optional": True,
|
||||||
},
|
},
|
||||||
{"type": "screenshot", "path": "{run_artifact_dir}/final.png"},
|
{"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
|
return context
|
||||||
|
|
||||||
|
|
||||||
_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"\{([^{}]+)\}")
|
_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:
|
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":
|
if kind == "goto":
|
||||||
page.goto(str(resolved["url"]), wait_until=resolved.get("wait_until", "domcontentloaded"), timeout=timeout_ms)
|
page.goto(str(resolved["url"]), wait_until=resolved.get("wait_until", "domcontentloaded"), timeout=timeout_ms)
|
||||||
elif kind == "click":
|
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":
|
elif kind == "fill":
|
||||||
_locator(page, str(resolved["selector"])).fill(str(resolved.get("text", "")), timeout=timeout_ms)
|
_locator(page, str(resolved["selector"])).fill(str(resolved.get("text", "")), timeout=timeout_ms)
|
||||||
elif kind == "press":
|
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.press("Control+A")
|
||||||
page.keyboard.insert_text(text)
|
page.keyboard.insert_text(text)
|
||||||
record["n_chars"] = len(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":
|
elif kind == "evaluate":
|
||||||
page.evaluate(str(resolved["script"]), resolved.get("arg"))
|
page.evaluate(str(resolved["script"]), resolved.get("arg"))
|
||||||
elif kind == "wait_for_selector":
|
elif kind == "wait_for_selector":
|
||||||
@@ -479,6 +579,8 @@ def run_browser_flow(
|
|||||||
raise ValueError("Browser config contains no actions")
|
raise ValueError("Browser config contains no actions")
|
||||||
|
|
||||||
records: list[dict[str, Any]] = []
|
records: list[dict[str, Any]] = []
|
||||||
|
failure: Exception | None = None
|
||||||
|
failure_artifacts: dict[str, str] = {}
|
||||||
with sync_playwright() as pw:
|
with sync_playwright() as pw:
|
||||||
browser = pw.chromium.launch(
|
browser = pw.chromium.launch(
|
||||||
headless=bool(config.get("headless", False) if headless is None else headless)
|
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 = context.new_page()
|
||||||
page.set_default_timeout(timeout_ms)
|
page.set_default_timeout(timeout_ms)
|
||||||
|
try:
|
||||||
for action in actions:
|
for action in actions:
|
||||||
records.append(_run_action(page, action, context_vars, timeout_ms))
|
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()
|
browser.close()
|
||||||
|
|
||||||
expected = manifest.get("expected_joinquant_csvs", {})
|
expected = manifest.get("expected_joinquant_csvs", {})
|
||||||
@@ -526,6 +648,7 @@ def run_browser_flow(
|
|||||||
"manifest_path": str(manifest_path),
|
"manifest_path": str(manifest_path),
|
||||||
"config_path": str(config_path),
|
"config_path": str(config_path),
|
||||||
"flow": flow,
|
"flow": flow,
|
||||||
|
"status": "failed" if failure else "ok",
|
||||||
"storage_state": str(storage_state),
|
"storage_state": str(storage_state),
|
||||||
"artifact_dir": str(artifact_dir),
|
"artifact_dir": str(artifact_dir),
|
||||||
"actions": records,
|
"actions": records,
|
||||||
@@ -538,6 +661,10 @@ def run_browser_flow(
|
|||||||
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
|
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
if failure is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Browser flow failed; report saved to {report_path}: {failure}"
|
||||||
|
) from failure
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -155,13 +155,19 @@ def reconcile_cmd(
|
|||||||
)
|
)
|
||||||
@click.option("--out-path", required=True, help="Path for generated standalone strategy")
|
@click.option("--out-path", required=True, help="Path for generated standalone strategy")
|
||||||
@click.option("--allow-short", is_flag=True, help="Do not clip negative targets in the generated wrapper")
|
@click.option("--allow-short", is_flag=True, help="Do not clip negative targets in the generated wrapper")
|
||||||
def write_wrapper_cmd(portfolio_name, mode, out_path, allow_short):
|
@click.option(
|
||||||
|
"--embed-targets-dir",
|
||||||
|
default=None,
|
||||||
|
help="Embed CSV target files from this directory into the strategy source",
|
||||||
|
)
|
||||||
|
def write_wrapper_cmd(portfolio_name, mode, out_path, allow_short, embed_targets_dir):
|
||||||
"""Generate a standalone JoinQuant wrapper strategy."""
|
"""Generate a standalone JoinQuant wrapper strategy."""
|
||||||
path = write_wrapper_strategy(
|
path = write_wrapper_strategy(
|
||||||
portfolio_name=portfolio_name,
|
portfolio_name=portfolio_name,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
out_path=out_path,
|
out_path=out_path,
|
||||||
allow_short=allow_short,
|
allow_short=allow_short,
|
||||||
|
embedded_targets_dir=embed_targets_dir,
|
||||||
)
|
)
|
||||||
click.echo(f"Saved JoinQuant wrapper strategy: {path}")
|
click.echo(f"Saved JoinQuant wrapper strategy: {path}")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ write a configured standalone file for a real run.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from string import Template
|
from string import Template
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -33,6 +34,7 @@ PORTFOLIO_NAME = "${portfolio_name}"
|
|||||||
TARGET_MODE = "${mode}"
|
TARGET_MODE = "${mode}"
|
||||||
ALLOW_SHORT = ${allow_short}
|
ALLOW_SHORT = ${allow_short}
|
||||||
TARGET_FILE_PREFIX = "" # Optional uploaded-file prefix, for example "run1/"
|
TARGET_FILE_PREFIX = "" # Optional uploaded-file prefix, for example "run1/"
|
||||||
|
_EMBEDDED_TARGETS = ${embedded_targets}
|
||||||
|
|
||||||
|
|
||||||
def initialize(context):
|
def initialize(context):
|
||||||
@@ -55,6 +57,7 @@ def _today_file_name(context):
|
|||||||
|
|
||||||
|
|
||||||
def _read_target_file(file_name):
|
def _read_target_file(file_name):
|
||||||
|
${embedded_target_read}
|
||||||
data = read_file(TARGET_FILE_PREFIX + file_name)
|
data = read_file(TARGET_FILE_PREFIX + file_name)
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
data = data.decode("utf-8")
|
data = data.decode("utf-8")
|
||||||
@@ -201,6 +204,8 @@ exec(_WRAPPER_TEMPLATE.substitute(
|
|||||||
portfolio_name="run1",
|
portfolio_name="run1",
|
||||||
mode="target_shares",
|
mode="target_shares",
|
||||||
allow_short="False",
|
allow_short="False",
|
||||||
|
embedded_targets="{}",
|
||||||
|
embedded_target_read="",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@@ -209,23 +214,47 @@ def render_wrapper_strategy(
|
|||||||
portfolio_name: str,
|
portfolio_name: str,
|
||||||
mode: WrapperMode = "target_shares",
|
mode: WrapperMode = "target_shares",
|
||||||
allow_short: bool = False,
|
allow_short: bool = False,
|
||||||
|
embedded_targets: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render the standalone JoinQuant wrapper strategy source."""
|
"""Render the standalone JoinQuant wrapper strategy source."""
|
||||||
if mode not in {"target_shares", "target_value"}:
|
if mode not in {"target_shares", "target_value"}:
|
||||||
raise ValueError("mode must be 'target_shares' or 'target_value'")
|
raise ValueError("mode must be 'target_shares' or 'target_value'")
|
||||||
|
embedded_targets = embedded_targets or {}
|
||||||
|
embedded_target_read = ""
|
||||||
|
if embedded_targets:
|
||||||
|
embedded_target_read = (
|
||||||
|
" if file_name in _EMBEDDED_TARGETS:\n"
|
||||||
|
" return _EMBEDDED_TARGETS[file_name]\n"
|
||||||
|
)
|
||||||
return _WRAPPER_TEMPLATE.substitute(
|
return _WRAPPER_TEMPLATE.substitute(
|
||||||
portfolio_name=portfolio_name,
|
portfolio_name=portfolio_name,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
allow_short="True" if allow_short else "False",
|
allow_short="True" if allow_short else "False",
|
||||||
|
embedded_targets=json.dumps(embedded_targets, ensure_ascii=False, indent=4),
|
||||||
|
embedded_target_read=embedded_target_read,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_embedded_targets(targets_dir: str | Path | None) -> dict[str, str]:
|
||||||
|
if targets_dir is None:
|
||||||
|
return {}
|
||||||
|
root = Path(targets_dir)
|
||||||
|
targets = {
|
||||||
|
path.name: path.read_text(encoding="utf-8")
|
||||||
|
for path in sorted(root.glob("*.csv"))
|
||||||
|
}
|
||||||
|
if not targets:
|
||||||
|
raise ValueError(f"No CSV target files found under {root}")
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def write_wrapper_strategy(
|
def write_wrapper_strategy(
|
||||||
*,
|
*,
|
||||||
portfolio_name: str,
|
portfolio_name: str,
|
||||||
mode: WrapperMode = "target_shares",
|
mode: WrapperMode = "target_shares",
|
||||||
out_path: str | Path,
|
out_path: str | Path,
|
||||||
allow_short: bool = False,
|
allow_short: bool = False,
|
||||||
|
embedded_targets_dir: str | Path | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Write a configured standalone JoinQuant wrapper strategy."""
|
"""Write a configured standalone JoinQuant wrapper strategy."""
|
||||||
path = Path(out_path)
|
path = Path(out_path)
|
||||||
@@ -235,6 +264,7 @@ def write_wrapper_strategy(
|
|||||||
portfolio_name=portfolio_name,
|
portfolio_name=portfolio_name,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
allow_short=allow_short,
|
allow_short=allow_short,
|
||||||
|
embedded_targets=_load_embedded_targets(embedded_targets_dir),
|
||||||
),
|
),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -514,6 +514,28 @@ def test_wrapper_strategy_generation_smoke(tmp_path):
|
|||||||
assert "order_target_value" in text
|
assert "order_target_value" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrapper_strategy_can_embed_target_csvs(tmp_path):
|
||||||
|
targets_dir = tmp_path / "targets"
|
||||||
|
targets_dir.mkdir()
|
||||||
|
(targets_dir / "20260701.csv").write_text(
|
||||||
|
"date,portfolio_name,symbol_id,jq_symbol,target_shares,target_value,export_mode\n"
|
||||||
|
"2026-07-01,run1,sh600000,600000.XSHG,1000,10000,target_shares\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
path = write_wrapper_strategy(
|
||||||
|
portfolio_name="run1",
|
||||||
|
mode="target_shares",
|
||||||
|
out_path=tmp_path / "wrapper_embedded.py",
|
||||||
|
embedded_targets_dir=targets_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = path.read_text()
|
||||||
|
assert '"20260701.csv"' in text
|
||||||
|
assert "600000.XSHG" in text
|
||||||
|
assert "if file_name in _EMBEDDED_TARGETS" in text
|
||||||
|
|
||||||
|
|
||||||
def test_build_fixed_share_positions_excludes_final_executionless_date():
|
def test_build_fixed_share_positions_excludes_final_executionless_date():
|
||||||
data = pd.DataFrame({
|
data = pd.DataFrame({
|
||||||
"symbol_id": ["sh600000", "sh600000", "sh600000"],
|
"symbol_id": ["sh600000", "sh600000", "sh600000"],
|
||||||
@@ -558,6 +580,8 @@ def test_browser_config_template_and_placeholder_resolution(tmp_path):
|
|||||||
"/tmp/20240111.csv",
|
"/tmp/20240111.csv",
|
||||||
]
|
]
|
||||||
assert resolve_template("save:{expected_joinquant_csvs.fills}", context) == "save:/tmp/jq_fills.csv"
|
assert resolve_template("save:{expected_joinquant_csvs.fills}", context) == "save:/tmp/jq_fills.csv"
|
||||||
|
script = "(arg) => { return new Event('change', {bubbles: true}); }"
|
||||||
|
assert resolve_template(script, context) == script
|
||||||
|
|
||||||
|
|
||||||
def test_load_env_file_handles_quotes_without_shell_sourcing(tmp_path):
|
def test_load_env_file_handles_quotes_without_shell_sourcing(tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user