413 lines
15 KiB
Python
413 lines
15 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src.views import dashboard
|
|
|
|
|
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
|
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
|
|
|
|
|
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [
|
|
{
|
|
"id": 1,
|
|
"number": 7,
|
|
"title": "Assigned issue",
|
|
"state": "open",
|
|
"repository": "stackchain/mobile",
|
|
"labels": [],
|
|
"assignees": ["timmy"],
|
|
"updated_at": "2026-08-06T12:00:00Z",
|
|
"url": "https://forge.example/mobile/issues/7",
|
|
},
|
|
{
|
|
"id": 2,
|
|
"number": 7,
|
|
"title": "Priority issue",
|
|
"state": "open",
|
|
"repository": "stackchain/api",
|
|
"labels": ["P0"],
|
|
"assignees": [],
|
|
"updated_at": "2026-08-06T11:00:00Z",
|
|
"url": "https://forge.example/api/issues/7",
|
|
},
|
|
],
|
|
"pull_requests": [
|
|
{
|
|
"id": 3,
|
|
"number": 4,
|
|
"title": "Review PR",
|
|
"state": "open",
|
|
"repository": "stackchain/web",
|
|
"work_reasons": ["review_requested"],
|
|
"updated_at": "2026-08-06T13:00:00Z",
|
|
"url": "https://forge.example/web/pulls/4",
|
|
}
|
|
],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const queue = buildMyWork({json.dumps(payload)});
|
|
process.stdout.write(JSON.stringify(queue));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
queue = json.loads(result.stdout)
|
|
|
|
assert [item["title"] for item in queue] == [
|
|
"Priority issue",
|
|
"Review PR",
|
|
"Assigned issue",
|
|
]
|
|
assert queue[0]["key"] == "stackchain/api#7"
|
|
assert queue[0]["reason"] == "P0 priority"
|
|
assert queue[1]["reason"] == "Needs your review"
|
|
assert queue[1]["is_review"] is True
|
|
assert queue[2]["reason"] == "Assigned to you"
|
|
|
|
|
|
def test_my_work_reviews_filter_and_summary_are_actionable():
|
|
items = [
|
|
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
|
{"title": "Assigned PR", "kind": "pull", "is_review": False, "is_assigned": True},
|
|
{"title": "Review PR", "kind": "pull", "is_review": True, "is_assigned": False},
|
|
]
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = {json.dumps(items)};
|
|
process.stdout.write(JSON.stringify({{
|
|
reviews: buildMyWork.filterMyWork(items, 'review'),
|
|
summary: buildMyWork.summarizeMyWork(items),
|
|
}}));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert [item["title"] for item in output["reviews"]] == ["Review PR"]
|
|
assert output["summary"] == "1 review · 2 assigned"
|
|
|
|
|
|
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
|
|
items = [
|
|
{"kind": "issue", "is_review": False},
|
|
{"kind": "pull", "is_review": False},
|
|
{"kind": "pull", "is_review": True},
|
|
]
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
|
html = await dashboard()
|
|
|
|
assert html.index('id="my-work"') < html.index('data-panel-key="context"')
|
|
assert 'data-work-filter="all"' in html
|
|
assert 'data-work-filter="issue"' in html
|
|
assert 'data-work-filter="pull"' in html
|
|
assert 'data-work-filter="review"' in html
|
|
assert '.work-filter' in html and 'min-height: 44px' in html
|
|
assert '.my-work-card' in html and 'min-height: 44px' in html
|
|
assert '<script src="static/my-work.js"></script>' in html
|
|
assert "buildMyWork(data)" in html
|
|
assert "markMyWorkStale()" in html
|
|
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
|
html = await dashboard()
|
|
|
|
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
|
assert 'data-work-count="all"' in html
|
|
assert 'data-work-count="issue"' in html
|
|
assert 'data-work-count="pull"' in html
|
|
assert 'data-work-count="review"' in html
|
|
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
|
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
|
|
|
|
|
def test_review_controller_loads_encoded_cross_repo_detail_path():
|
|
script = f"""
|
|
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
|
let request;
|
|
const controller = createReviewController({{ fetchJson: async (url, options) => {{
|
|
request = {{ url, accept: options.headers.Accept }};
|
|
return {{ title: 'Review API' }};
|
|
}} }});
|
|
controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
|
|
process.stdout.write(JSON.stringify({{ request, title: detail.title }}))
|
|
);
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"request": {
|
|
"url": "api/v1/repos/stackchain/api/pulls/7/review",
|
|
"accept": "application/json",
|
|
},
|
|
"title": "Review API",
|
|
}
|
|
|
|
|
|
def test_review_diff_rows_escape_content_and_toggle_accessibly():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const escapeHtml = value => String(value)
|
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
const html = reviewSheet.renderDiffFile({{
|
|
filename: 'src/<api>.py', status: 'modified', additions: 1, deletions: 1,
|
|
diff_available: true, diff_truncated: true,
|
|
diff_lines: ['@@ -1 +1 @@', '-old <token>', '+new & safe']
|
|
}}, 2, escapeHtml);
|
|
const button = {{ attrs: {{ 'aria-expanded': 'false' }}, getAttribute(k) {{ return this.attrs[k]; }}, setAttribute(k,v) {{ this.attrs[k]=v; }} }};
|
|
const panel = {{ hidden: true }};
|
|
reviewSheet.toggleDiff(button, panel);
|
|
process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expanded'], hidden: panel.hidden }}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert 'aria-expanded="false"' in output["html"]
|
|
assert 'src/<api>.py' in output["html"]
|
|
assert '-old <token>' in output["html"]
|
|
assert '+new & safe' in output["html"]
|
|
assert 'Preview truncated' in output["html"]
|
|
assert 'class="review-mark"' in output["html"]
|
|
assert 'data-review-filename="src/<api>.py"' in output["html"]
|
|
assert 'Mark reviewed' in output["html"]
|
|
assert output["expanded"] == "true"
|
|
assert output["hidden"] is False
|
|
|
|
|
|
def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
|
|
setItem(key, value) {{ values.set(key, value); }}
|
|
}};
|
|
const options = {{
|
|
storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
|
|
files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}, {{filename:'README.md'}}]
|
|
}};
|
|
const first = reviewSheet.createProgress(options);
|
|
const before = first.snapshot();
|
|
const marked = first.markReviewed('src/a.py');
|
|
const restored = reviewSheet.createProgress(options).snapshot();
|
|
process.stdout.write(JSON.stringify({{ before, marked, restored }}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["before"] == {
|
|
"reviewed": [], "reviewedCount": 0, "total": 3, "nextFilename": "src/a.py"
|
|
}
|
|
assert output["marked"] == {
|
|
"reviewed": ["src/a.py"], "reviewedCount": 1, "total": 3,
|
|
"nextFilename": "src/b.py",
|
|
}
|
|
assert output["restored"] == output["marked"]
|
|
|
|
|
|
def test_review_progress_resets_when_new_commits_change_the_head_sha():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
|
|
setItem(key, value) {{ values.set(key, value); }}
|
|
}};
|
|
const base = {{ storage, repository: 'stackchain/api', number: 7, files: [{{filename:'src/a.py'}}] }};
|
|
reviewSheet.createProgress({{...base, headSha:'abc123'}}).markReviewed('src/a.py');
|
|
const changed = reviewSheet.createProgress({{...base, headSha:'def456'}}).snapshot();
|
|
process.stdout.write(JSON.stringify(changed));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"reviewed": [], "reviewedCount": 0, "total": 1,
|
|
"nextFilename": "src/a.py", "newHead": True,
|
|
}
|
|
|
|
|
|
def test_review_feedback_draft_restores_notes_summary_and_decision_for_same_head():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
|
|
setItem(key, value) {{ values.set(key, value); }}
|
|
}};
|
|
const options = {{
|
|
storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
|
|
files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}]
|
|
}};
|
|
const first = reviewSheet.createDraft(options);
|
|
first.setNote('src/a.py', 'Handle the empty state.');
|
|
first.setSummary('One blocker remains.');
|
|
first.setDecision('request_changes');
|
|
const restored = reviewSheet.createDraft(options).snapshot();
|
|
process.stdout.write(JSON.stringify(restored));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"notes": {"src/a.py": "Handle the empty state."},
|
|
"summary": "One blocker remains.",
|
|
"decision": "request_changes",
|
|
}
|
|
|
|
|
|
def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const markdown = reviewSheet.formatFeedback({{
|
|
decision: 'request_changes', summary: 'One blocker remains.',
|
|
notes: {{'src/b.py':'Second note', 'src/a.py':'First note', 'README.md':''}}
|
|
}}, [{{filename:'src/a.py'}}, {{filename:'README.md'}}, {{filename:'src/b.py'}}]);
|
|
process.stdout.write(markdown);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert result.stdout == (
|
|
"## Intended decision\nRequest changes\n\n"
|
|
"## Summary\nOne blocker remains.\n\n"
|
|
"## File notes\n### `src/a.py`\nFirst note\n\n"
|
|
"### `src/b.py`\nSecond note"
|
|
)
|
|
|
|
|
|
def test_review_handoff_opens_gitea_only_after_copy_and_preserves_failed_copy():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const events = [];
|
|
const success = reviewSheet.copyAndContinue({{
|
|
text: 'feedback', url: 'https://forge.example/pulls/7',
|
|
copy: async text => events.push('copy:' + text),
|
|
open: url => events.push('open:' + url),
|
|
fallback: text => events.push('fallback:' + text),
|
|
}});
|
|
const failedEvents = [];
|
|
const failure = reviewSheet.copyAndContinue({{
|
|
text: 'keep me', url: 'https://forge.example/pulls/8',
|
|
copy: async () => {{ throw new Error('denied'); }},
|
|
open: url => failedEvents.push('open:' + url),
|
|
fallback: text => failedEvents.push('fallback:' + text),
|
|
}});
|
|
Promise.all([success, failure]).then(results => process.stdout.write(JSON.stringify({{
|
|
events, failedEvents, results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"events": ["copy:feedback", "open:https://forge.example/pulls/7"],
|
|
"failedEvents": ["fallback:keep me"],
|
|
"results": [True, False],
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_review_requests_open_an_accessible_mobile_detail_sheet():
|
|
html = await dashboard()
|
|
|
|
assert 'id="review-sheet"' in html
|
|
assert 'role="dialog"' in html and 'aria-modal="true"' in html
|
|
assert 'id="review-sheet-status"' in html and 'aria-live="polite"' in html
|
|
assert 'id="open-review-gitea"' in html and 'rel="noopener noreferrer"' in html
|
|
assert '<script src="static/review-sheet.js"></script>' in html
|
|
assert '@media (max-width: 600px)' in html
|
|
assert '.review-sheet-panel' in html and 'width:100%' in html
|
|
assert '.review-action' in html and 'min-height:44px' in html
|
|
assert '.review-file-toggle' in html and 'min-height:44px' in html
|
|
assert '.review-mark' in html and 'min-height:44px' in html
|
|
assert 'id="review-progress"' in html and 'aria-live="polite"' in html
|
|
assert 'id="next-unreviewed-review"' in html
|
|
assert '.review-progress-actions' in html and 'position:sticky' in html
|
|
assert "createReviewController.createProgress" in html
|
|
assert "progress.markReviewed" in html
|
|
assert "scrollIntoView" in html
|
|
assert '.review-diff' in html and 'overflow-x:auto' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
|
|
html = await dashboard()
|
|
|
|
assert 'data-review-index' in html
|
|
assert "reviewController.load(selectedReview)" in html
|
|
assert "review-files" in html
|
|
assert "review-history" in html
|
|
assert "open-review-gitea" in html
|
|
assert "reviewController.submit" not in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
|
|
html = await dashboard()
|
|
review_script = REVIEW_SHEET.read_text()
|
|
|
|
assert 'class="review-note"' in review_script
|
|
assert 'id="review-decision"' in html
|
|
assert 'id="review-summary"' in html
|
|
assert 'id="copy-review-feedback"' in html
|
|
assert 'id="review-copy-fallback"' in html
|
|
assert '.review-note' in html and 'min-height:88px' in html
|
|
assert '.review-handoff' in html and 'position:sticky' in html
|
|
assert "createReviewController.createDraft" in html
|
|
assert "draft.setNote" in html
|
|
assert "draft?.setSummary" in html
|
|
assert "draft?.setDecision" in html
|
|
assert "createReviewController.formatFeedback" in html
|
|
assert "createReviewController.copyAndContinue" in html
|
|
assert "navigator.clipboard.writeText" in html
|
|
assert "window.open(url, '_blank', 'noopener,noreferrer')" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
|
|
html = await dashboard()
|
|
|
|
assert 'id="retry-review-load"' in html
|
|
assert '.review-retry' in html and 'min-height:44px' in html
|
|
assert "qs('#retry-review-load').hidden = false" in html
|
|
assert "qs('#retry-review-load').hidden = true" in html
|
|
assert "openReviewSheet(selectedReview, reviewTrigger)" in html
|
|
assert "qs('#retry-review-load').focus()" in html
|