160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""Lift real production JS out of the shipped files and execute it in node.
|
|
|
|
WHY LIFT RATHER THAN IMPORT: the manager's front-end modules pull in
|
|
``../../scripts/app.js`` and touch ``document`` at module scope, so node cannot
|
|
import them. WHY LIFT RATHER THAN RETYPE: a retyped copy of a formatter stops
|
|
tracking the formatter the moment someone edits it, and then the guard passes
|
|
while the shipped code regresses. Lifting keeps the executed bytes the shipped
|
|
bytes — if the production function changes, so does the text under test.
|
|
|
|
This mirrors tests/test_manager_markdown_escaping.py, which AST-extracts
|
|
``convert_markdown_to_html`` rather than importing manager_server.
|
|
|
|
Consumers point ``JsSource`` at a directory of .js files; an override directory
|
|
(e.g. one populated with ``git show <base>:js/foo.js``) makes the RED half of a
|
|
fix reproducible in one command.
|
|
"""
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _skip_literal(source: str, pos: int) -> int:
|
|
"""If a literal or comment starts at pos, return the index just past it.
|
|
|
|
Returns pos unchanged when nothing starts there. Template literals are
|
|
consumed whole, which is safe for brace counting because a `${...}`
|
|
substitution is itself balanced. A lone `/` is read as a regex literal —
|
|
sanitizeHTML's own `.replace(/'/g, ...)` hides an apostrophe inside one, and
|
|
no scanned range uses `/` as division. Were that to change, the assertion
|
|
below fails loudly rather than returning a wrong slice.
|
|
"""
|
|
ch = source[pos]
|
|
nxt = source[pos + 1] if pos + 1 < len(source) else ""
|
|
if ch == "/" and nxt == "/":
|
|
return source.index("\n", pos)
|
|
if ch == "/" and nxt == "*":
|
|
return source.index("*/", pos) + 2
|
|
if ch not in "\"'`/":
|
|
return pos
|
|
cursor = pos + 1
|
|
while cursor < len(source):
|
|
if source[cursor] == "\\":
|
|
cursor += 2
|
|
continue
|
|
if source[cursor] == ch:
|
|
return cursor + 1
|
|
cursor += 1
|
|
raise AssertionError("unterminated literal at offset %d" % (pos,))
|
|
|
|
|
|
def slice_braced(source: str, start_marker: str) -> str:
|
|
"""Return start_marker plus the balanced {...} block that follows it."""
|
|
start = source.index(start_marker)
|
|
pos = source.index("{", start + len(start_marker))
|
|
depth = 0
|
|
while pos < len(source):
|
|
skipped = _skip_literal(source, pos)
|
|
if skipped != pos:
|
|
pos = skipped
|
|
continue
|
|
if source[pos] == "{":
|
|
depth += 1
|
|
elif source[pos] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return source[start:pos + 1]
|
|
pos += 1
|
|
raise AssertionError("unbalanced braces after %r" % (start_marker,))
|
|
|
|
|
|
def slice_object_entry(source: str, marker: str, indent: str = "\t\t") -> str:
|
|
"""Return one object literal out of a `}, {`-chained array, by id marker.
|
|
|
|
These objects are literals inside an array, so the first `{` after the
|
|
marker belongs to the NEXT entry — brace matching cannot be used. The entry
|
|
instead ends at the first `}` sitting at the array's own indent.
|
|
"""
|
|
start = source.index(marker)
|
|
end = source.index("\n%s}" % indent, start)
|
|
return source[start:end]
|
|
|
|
|
|
def line_containing(source: str, marker: str) -> str:
|
|
"""Return the single source line that holds `marker` (fails if absent)."""
|
|
start = source.index(marker)
|
|
line_start = source.rfind("\n", 0, start) + 1
|
|
return source[line_start:source.index("\n", start)]
|
|
|
|
|
|
class JsSource:
|
|
"""The shipped .js files under test, read from `js_dir`."""
|
|
|
|
def __init__(self, js_dir):
|
|
self.js_dir = Path(js_dir)
|
|
self._cache = {}
|
|
|
|
def text(self, filename: str) -> str:
|
|
if filename not in self._cache:
|
|
self._cache[filename] = (self.js_dir / filename).read_text(encoding="utf-8")
|
|
return self._cache[filename]
|
|
|
|
def lift_declaration(self, filename: str, marker: str) -> str:
|
|
"""Lift an `export function`/`export const` declaration, minus `export`."""
|
|
return slice_braced(self.text(filename), marker).replace("export ", "", 1)
|
|
|
|
def lift_formatter(self, filename: str, anchor: str) -> str:
|
|
"""Lift the `formatter: ...` at `anchor` as a bare function expression."""
|
|
block = slice_braced(self.text(filename), anchor)
|
|
marker = "formatter:"
|
|
return block[block.index(marker) + len(marker):].strip()
|
|
|
|
def lift_span(self, filename: str, start_marker: str, end_marker: str) -> str:
|
|
"""Lift the source between two markers, end_marker included."""
|
|
source = self.text(filename)
|
|
start = source.index(start_marker)
|
|
end = source.index(end_marker, start) + len(end_marker)
|
|
return source[start:end]
|
|
|
|
|
|
def run_node(script: str) -> dict:
|
|
"""Execute an ES-module script and parse the single JSON object it prints."""
|
|
assert NODE is not None, "node is required to execute the lifted production JS"
|
|
result = subprocess.run(
|
|
[NODE, "--input-type=module", "-e", script],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
assert result.returncode == 0, "node failed:\n%s" % (result.stderr,)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
class Collector(HTMLParser):
|
|
"""Collects the tags and attributes a browser would actually build."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.tags = []
|
|
self.attrs = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
self.tags.append(tag)
|
|
self.attrs.extend(attrs)
|
|
|
|
handle_startendtag = handle_starttag
|
|
|
|
|
|
def parse(markup: str) -> Collector:
|
|
collector = Collector()
|
|
collector.feed(markup)
|
|
collector.close()
|
|
return collector
|
|
|
|
|
|
def event_handlers(markup: str):
|
|
"""Every on* attribute a browser would attach for this markup."""
|
|
return [name for name, _ in parse(markup).attrs if name.lower().startswith("on")]
|