260 lines
8.8 KiB
Python
260 lines
8.8 KiB
Python
import json
|
|
import re
|
|
import subprocess
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from urllib.parse import urljoin
|
|
|
|
|
|
FRONTEND = Path(__file__).parent.parent / "frontend"
|
|
RENDERER = FRONTEND / "markdown.js"
|
|
|
|
|
|
def render_markdown(payload, options=None):
|
|
options = options or {}
|
|
script = (
|
|
f"const render = require({json.dumps(str(RENDERER))});"
|
|
f"process.stdout.write(render({json.dumps(payload)}, {json.dumps(options)}));"
|
|
)
|
|
return subprocess.run(
|
|
["node", "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
|
|
|
|
def toggle_task(payload, task_index, checked):
|
|
script = (
|
|
f"const render = require({json.dumps(str(RENDERER))});"
|
|
f"process.stdout.write(JSON.stringify(render.toggleTask({json.dumps(payload)}, "
|
|
f"{task_index}, {json.dumps(checked)})));"
|
|
)
|
|
return json.loads(
|
|
subprocess.run(
|
|
["node", "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
)
|
|
|
|
|
|
def manage_task(payload, task_index, operation):
|
|
script = (
|
|
f"const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;"
|
|
f"process.stdout.write(JSON.stringify(manage({json.dumps(payload)}, "
|
|
f"{task_index}, {json.dumps(operation)})));"
|
|
)
|
|
return json.loads(
|
|
subprocess.run(
|
|
["node", "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
)
|
|
|
|
|
|
class ScriptSourceParser(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.sources = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag == "script":
|
|
source = dict(attrs).get("src")
|
|
if source:
|
|
self.sources.append(source)
|
|
|
|
|
|
def test_markdown_renderer_escapes_raw_html_before_rendering_heading():
|
|
payload = "# <img src=x onerror=alert(document.domain)>"
|
|
script = (
|
|
f"const render = require({json.dumps(str(RENDERER))});"
|
|
f"process.stdout.write(render({json.dumps(payload)}));"
|
|
)
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
assert result.stdout == (
|
|
"<h1><img src=x onerror=alert(document.domain)></h1>"
|
|
)
|
|
|
|
|
|
def test_markdown_script_resolves_inside_dashboard_subpath():
|
|
parser = ScriptSourceParser()
|
|
parser.feed((FRONTEND / "index.html").read_text())
|
|
markdown_source = next(
|
|
source for source in parser.sources if source.endswith("markdown.js")
|
|
)
|
|
|
|
assert urljoin(
|
|
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
|
|
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
|
|
|
|
|
|
def test_markdown_renderer_preserves_mobile_reading_structure():
|
|
rendered = render_markdown(
|
|
"## Plan\n\n> Ship this\n\n- [x] Tested\n- [ ] Released\n\n"
|
|
"```python\nprint('ready')\n```"
|
|
)
|
|
|
|
assert rendered == (
|
|
"<h2>Plan</h2><blockquote><p>Ship this</p></blockquote>"
|
|
'<ul class="task-list"><li class="task-list-item">'
|
|
'<input type="checkbox" disabled checked> Tested</li>'
|
|
'<li class="task-list-item"><input type="checkbox" disabled> Released</li></ul>'
|
|
'<pre><code class="language-python">print('ready')</code></pre>'
|
|
)
|
|
|
|
|
|
def test_interactive_markdown_tasks_expose_accessible_source_indices():
|
|
rendered = render_markdown(
|
|
"- [ ] Verify production\n- [x] Notify support",
|
|
{"interactiveTasks": True},
|
|
)
|
|
|
|
assert rendered == (
|
|
'<ul class="task-list"><li class="task-list-item">'
|
|
'<input type="checkbox" class="task-list-toggle" data-task-index="0" '
|
|
'aria-label="Mark Verify production complete"> Verify production</li>'
|
|
'<li class="task-list-item"><input type="checkbox" class="task-list-toggle" '
|
|
'data-task-index="1" aria-label="Mark Notify support incomplete" checked> '
|
|
'Notify support</li></ul>'
|
|
)
|
|
|
|
|
|
def test_manageable_markdown_tasks_expose_touch_action_with_position_boundaries():
|
|
rendered = render_markdown(
|
|
"- [ ] Verify production\n- [x] Notify support",
|
|
{"interactiveTasks": True, "manageTasks": True},
|
|
)
|
|
|
|
assert 'class="task-list-manage" data-task-index="0"' in rendered
|
|
assert 'aria-label="Manage step: Verify production"' in rendered
|
|
assert 'data-task-first="true"' in rendered
|
|
assert 'class="task-list-manage" data-task-index="1"' in rendered
|
|
assert 'data-task-last="true"' in rendered
|
|
|
|
|
|
def test_interactive_task_toggle_changes_exact_source_marker_only():
|
|
body = (
|
|
"```md\r\n- [ ] example only\r\n```\r\n"
|
|
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [ ] Duplicate\r\n"
|
|
)
|
|
|
|
assert toggle_task(body, 1, True) == (
|
|
"```md\r\n- [ ] example only\r\n```\r\n"
|
|
"- [ ] Duplicate\r\n - [X] Nested duplicate\r\n- [x] Duplicate\r\n"
|
|
)
|
|
|
|
|
|
def test_manage_task_renames_exact_visible_task_and_preserves_nested_step_line_endings_and_fences():
|
|
body = (
|
|
"```md\r\n- [ ] example only\r\n```\r\n"
|
|
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Release\r\n"
|
|
)
|
|
|
|
assert manage_task(body, 1, {"action": "rename", "label": "Ship"}) == (
|
|
"```md\r\n- [ ] example only\r\n```\r\n"
|
|
"- [ ] Build\r\n - [X] Verify rollback\r\n- [ ] Ship\r\n"
|
|
)
|
|
|
|
|
|
def test_manage_task_removes_only_the_selected_task_line():
|
|
body = "Plan\n- [ ] Build\n- [x] Test\n\nNotes"
|
|
|
|
assert manage_task(body, 0, {"action": "remove"}) == "Plan\n- [x] Test\n\nNotes"
|
|
|
|
|
|
def test_manage_task_moves_a_step_within_its_list_without_changing_step_content():
|
|
body = "Plan\n- [ ] Build\n- [x] Test\n- [ ] Release\n\nNotes"
|
|
|
|
assert manage_task(body, 2, {"action": "move-earlier"}) == (
|
|
"Plan\n- [ ] Build\n- [ ] Release\n- [x] Test\n\nNotes"
|
|
)
|
|
assert manage_task(body, 0, {"action": "move-later"}) == (
|
|
"Plan\n- [x] Test\n- [ ] Build\n- [ ] Release\n\nNotes"
|
|
)
|
|
|
|
|
|
def test_manage_task_rejects_an_empty_or_duplicate_renamed_label():
|
|
script = f"""
|
|
const manage = require({json.dumps(str(FRONTEND / 'issue-sheet.js'))}).manageChecklistTask;
|
|
const body = '- [ ] Build\\n- [x] Test';
|
|
const errors = ['', ' test '].map(label => {{
|
|
try {{ manage(body, 0, {{action:'rename',label}}); return null; }}
|
|
catch (error) {{ return error.message; }}
|
|
}});
|
|
process.stdout.write(JSON.stringify(errors));
|
|
"""
|
|
|
|
assert json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout) == ["Enter a checklist step.", "That checklist step already exists."]
|
|
|
|
|
|
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
|
rendered = render_markdown(
|
|
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
|
"[relative](/git/org/repo/issues/1) "
|
|
"[attack](javascript:alert(1)) <img src=x onerror=alert(2)>"
|
|
)
|
|
|
|
assert (
|
|
'<a href="https://forge.example/work?q=1&safe=yes" target="_blank" '
|
|
'rel="noopener noreferrer">Forge</a>' in rendered
|
|
)
|
|
assert (
|
|
'<a href="/git/org/repo/issues/1" target="_blank" '
|
|
'rel="noopener noreferrer">relative</a>' in rendered
|
|
)
|
|
assert "javascript:" not in rendered
|
|
assert "<img" not in rendered
|
|
assert "<img src=x onerror=alert(2)>" in rendered
|
|
|
|
|
|
def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
|
dashboard = (FRONTEND / "dashboard.js").read_text()
|
|
|
|
expected_paths = (
|
|
"renderMarkdown(comment.body || 'No comment body provided.')",
|
|
"renderMarkdown(detail.subject_body || 'No subject context was provided.')",
|
|
"renderMarkdown(detail.body || 'No description provided.')",
|
|
"renderMarkdown(item.body || 'No description provided.')",
|
|
"renderMarkdown(review.body)",
|
|
"issueController.renderTasks(qs('#issue-sheet-body'), detail,",
|
|
)
|
|
for path in expected_paths:
|
|
assert path in dashboard
|
|
|
|
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 3
|
|
|
|
|
|
def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
|
html = (FRONTEND / "index.html").read_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
worker = (FRONTEND / "service-worker.js").read_text()
|
|
|
|
for body_id in (
|
|
"issue-sheet-body",
|
|
"pull-sheet-body",
|
|
"review-sheet-body",
|
|
"update-subject-body",
|
|
"search-preview-body",
|
|
):
|
|
body_tag = re.search(rf'<div\b[^>]*\bid="{body_id}"[^>]*>', html)
|
|
assert body_tag
|
|
assert "markdown-content" in body_tag.group(0)
|
|
|
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
|
assert ".markdown-content a { min-height:44px;" in css
|
|
assert "stackchain-dashboard-shell-v108" in worker
|