Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
__pycache__/
.pytest_cache/
117 changes: 88 additions & 29 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,116 @@
"""
Extract scheduling functions directly from the workflow pre-step heredoc.

Instead of duplicating the workflow's Python code in a separate module, we parse
workflows/autoloop.md, extract the Python heredoc, pull out function definitions
via the AST, and exec them into a namespace that tests can import from.
Instead of duplicating the workflow's JavaScript code in a separate module, we parse
workflows/autoloop.md, extract the JavaScript heredoc, write the function definitions
to a temp CommonJS module, and call them via Node.js subprocess.

This ensures tests always run against the actual workflow code.
"""

import ast
import json
import os
import re
import textwrap
import subprocess
import tempfile
from datetime import timedelta

WORKFLOW_PATH = os.path.join(os.path.dirname(__file__), "..", "workflows", "autoloop.md")

# Path to the extracted JS module
_JS_MODULE_PATH = os.path.join(tempfile.gettempdir(), "autoloop_test_functions.cjs")


def _load_workflow_functions():
"""Parse workflows/autoloop.md and extract Python function defs from the pre-step."""
"""Parse workflows/autoloop.md and extract JS function defs from the pre-step."""
with open(WORKFLOW_PATH) as f:
content = f.read()

# Extract the Python heredoc between PYEOF markers
m = re.search(r"python3 - << 'PYEOF'\n(.*?)\n\s*PYEOF", content, re.DOTALL)
assert m, "Could not find PYEOF heredoc in workflows/autoloop.md"
source = textwrap.dedent(m.group(1))
# Extract the JavaScript heredoc between JSEOF markers
m = re.search(r"node - << 'JSEOF'\n(.*?)\n\s*JSEOF", content, re.DOTALL)
assert m, "Could not find JSEOF heredoc in workflows/autoloop.md"
source = m.group(1)

# Extract function definitions: everything up to the main() async function.
# Functions are defined before 'async function main()'
lines = source.split("\n")
func_lines = []
for line in lines:
if line.strip().startswith("async function main"):
break
func_lines.append(line)

func_source = "\n".join(func_lines)

# Write to a temp .cjs file with module.exports
with open(_JS_MODULE_PATH, "w") as f:
f.write(func_source)
f.write(
"\n\nmodule.exports = "
"{ parseMachineState, parseSchedule, getProgramName, readProgramState };\n"
)

return True


def _call_js(func_name, *args):
"""Call a JS function from the extracted workflow module and return the result."""
args_json = json.dumps(list(args))
escaped_path = json.dumps(_JS_MODULE_PATH)
script = (
"const m = require(" + escaped_path + ");\n"
"const result = m." + func_name + "(..." + args_json + ");\n"
"process.stdout.write(JSON.stringify(result === undefined ? null : result));\n"
)
result = subprocess.run(
["node", "-e", script],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise RuntimeError("Node.js error calling " + func_name + ": " + result.stderr)
if not result.stdout.strip():
return None
return json.loads(result.stdout)


# Initialize at import time
_load_workflow_functions()


def _parse_schedule_wrapper(s):
"""Python wrapper for JS parseSchedule. Converts milliseconds to timedelta."""
ms = _call_js("parseSchedule", s)
if ms is None:
return None
return timedelta(milliseconds=ms)


def _parse_machine_state_wrapper(content):
"""Python wrapper for JS parseMachineState."""
return _call_js("parseMachineState", content)

# Parse AST and extract only top-level FunctionDef nodes
tree = ast.parse(source)
source_lines = source.splitlines(keepends=True)
func_sources = []
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.FunctionDef):
func_sources.append("".join(source_lines[node.lineno - 1 : node.end_lineno]))

# Execute function defs with their required imports
ns = {}
preamble = "import os, re, json\nfrom datetime import datetime, timezone, timedelta\n\n"
exec(preamble + "\n".join(func_sources), ns) # noqa: S102
return ns
def _get_program_name_wrapper(pf):
"""Python wrapper for JS getProgramName."""
return _call_js("getProgramName", pf)


# Load once at import time
_funcs = _load_workflow_functions()
_funcs = {
"parse_schedule": _parse_schedule_wrapper,
"parse_machine_state": _parse_machine_state_wrapper,
"get_program_name": _get_program_name_wrapper,
"read_program_state": lambda name: _call_js("readProgramState", name),
}


def _extract_inline_pattern(name):
"""Extract an inline code pattern from the workflow by name.
"""Extract the JavaScript heredoc source from the workflow.

This is a helper for extracting small inline patterns (like the slugify regex)
that aren't wrapped in function defs in the workflow source.
This is a helper for inspecting the full inline source if needed.
"""
with open(WORKFLOW_PATH) as f:
content = f.read()
m = re.search(r"python3 - << 'PYEOF'\n(.*?)\n\s*PYEOF", content, re.DOTALL)
return textwrap.dedent(m.group(1)) if m else ""
m = re.search(r"node - << 'JSEOF'\n(.*?)\n\s*JSEOF", content, re.DOTALL)
return m.group(1) if m else ""
37 changes: 19 additions & 18 deletions tests/test_scheduling.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
"""Tests for the scheduling pre-step in workflows/autoloop.md.

Functions are extracted directly from the workflow heredoc at import time
(see conftest.py) — there is no separate copy of the scheduling code.
Functions are extracted directly from the workflow JavaScript heredoc at import
time (see conftest.py) and called via Node.js subprocess — there is no separate
copy of the scheduling code.

For inline logic (slugify, frontmatter parsing, skip conditions, etc.) that
isn't wrapped in a function def in the workflow, we write thin test helpers
isn't wrapped in a named function in the workflow, we write thin test helpers
that replicate the exact inline pattern. These are documented with the
workflow source lines they correspond to.
workflow source patterns they correspond to.
"""

import re
Expand All @@ -27,14 +28,14 @@
# ---------------------------------------------------------------------------

def slugify_issue_title(title):
"""Replicates the inline slug logic at workflows/autoloop.md lines 236-237."""
"""Replicates the inline slug logic in the workflow's issue scanning section."""
slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')
slug = re.sub(r'-+', '-', slug)
return slug


def parse_frontmatter(content):
"""Replicates the inline frontmatter parsing at workflows/autoloop.md lines 316-330."""
"""Replicates the inline frontmatter parsing in the workflow's program scanning loop."""
content_stripped = re.sub(r'^(\s*<!--.*?-->\s*\n)*', '', content, flags=re.DOTALL)
schedule_delta = None
target_metric = None
Expand All @@ -53,7 +54,7 @@ def parse_frontmatter(content):


def is_unconfigured(content):
"""Replicates the inline unconfigured check at workflows/autoloop.md lines 306-312."""
"""Replicates the inline unconfigured check in the workflow's program scanning loop."""
if "<!-- AUTOLOOP:UNCONFIGURED -->" in content:
return True
if re.search(r'\bTODO\b|\bREPLACE', content):
Expand All @@ -62,7 +63,7 @@ def is_unconfigured(content):


def check_skip_conditions(state):
"""Replicates the inline skip logic at workflows/autoloop.md lines 347-361.
"""Replicates the inline skip logic in the workflow's program scanning loop.

Returns (should_skip, reason).
"""
Expand All @@ -80,7 +81,7 @@ def check_skip_conditions(state):


def check_if_due(schedule_delta, last_run, now):
"""Replicates the inline due check at workflows/autoloop.md lines 363-368.
"""Replicates the inline due check in the workflow's program scanning loop.

Returns (is_due, next_due_iso).
"""
Expand All @@ -91,7 +92,7 @@ def check_if_due(schedule_delta, last_run, now):


def select_program(due, forced_program=None, all_programs=None, unconfigured=None, issue_programs=None):
"""Replicates the selection logic at workflows/autoloop.md lines 379-409.
"""Replicates the selection logic in the workflow's program selection section.

Returns (selected, selected_file, selected_issue, selected_target_metric, deferred, error).
"""
Expand Down Expand Up @@ -312,7 +313,7 @@ def test_absolute_path_directory(self):


# ---------------------------------------------------------------------------
# slugify_issue_title (inline pattern, lines 236-237)
# slugify_issue_title (inline pattern, issue scanning section)
# ---------------------------------------------------------------------------

class TestSlugifyIssueTitle:
Expand Down Expand Up @@ -345,7 +346,7 @@ def test_consecutive_hyphens_collapsed(self):
assert slugify_issue_title("a b c") == "a-b-c"

def test_collision_dedup(self):
"""Replicates the slug collision dedup at workflows/autoloop.md lines 240-242."""
"""Replicates the slug collision dedup in the workflow's issue scanning section."""
# Simulate two issues that slugify to the same name
issue_programs = {}
titles = [("Improve Tests", 10), ("improve-tests", 20)]
Expand All @@ -363,7 +364,7 @@ def test_collision_dedup(self):


# ---------------------------------------------------------------------------
# parse_frontmatter (inline pattern, lines 316-330)
# parse_frontmatter (inline pattern, program scanning loop)
# ---------------------------------------------------------------------------

class TestParseFrontmatter:
Expand Down Expand Up @@ -416,7 +417,7 @@ def test_extra_frontmatter_fields_ignored(self):


# ---------------------------------------------------------------------------
# is_unconfigured (inline pattern, lines 306-312)
# is_unconfigured (inline pattern, program scanning loop)
# ---------------------------------------------------------------------------

class TestIsUnconfigured:
Expand Down Expand Up @@ -453,7 +454,7 @@ def test_issue_template_detected(self):


# ---------------------------------------------------------------------------
# check_skip_conditions (inline pattern, lines 347-361)
# check_skip_conditions (inline pattern, program scanning loop)
# ---------------------------------------------------------------------------

class TestCheckSkipConditions:
Expand Down Expand Up @@ -512,7 +513,7 @@ def test_completed_takes_priority_over_paused(self):


# ---------------------------------------------------------------------------
# check_if_due (inline pattern, lines 363-368)
# check_if_due (inline pattern, program scanning loop)
# ---------------------------------------------------------------------------

class TestCheckIfDue:
Expand Down Expand Up @@ -556,7 +557,7 @@ def test_next_due_timestamp(self):


# ---------------------------------------------------------------------------
# select_program (inline pattern, lines 379-409)
# select_program (inline pattern, program selection section)
# ---------------------------------------------------------------------------

class TestSelectProgram:
Expand Down Expand Up @@ -646,7 +647,7 @@ def test_forced_program_gets_target_metric_from_due(self):
def test_forced_program_not_in_due_select_returns_none(self):
# select_program itself returns None for target_metric when program isn't in due.
# The workflow's forced-program path has a fallback that parses target_metric
# directly from the program file (workflows/autoloop.md lines 399-410).
# directly from the program file (see forced-program fallback in the workflow).
due = []
all_progs = {"a": "a.md"}
selected, file, issue, target, deferred, err = select_program(
Expand Down
Loading
Loading