1016 lines
39 KiB
Python
1016 lines
39 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, "update": 0}
|
|
|
|
|
|
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [{
|
|
"id": 1, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z",
|
|
"url": "https://forge.example/stackchain/api/issues/7",
|
|
}],
|
|
"pull_requests": [],
|
|
"notifications": [
|
|
{
|
|
"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
|
"subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T12:00:00Z",
|
|
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
|
},
|
|
{
|
|
"id": 43, "number": 8, "title": "Mention only", "repository": "stackchain/web",
|
|
"subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T13:00:00Z",
|
|
"url": "https://forge.example/stackchain/web/issues/8#issuecomment-2",
|
|
},
|
|
],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const queue = buildMyWork({json.dumps(payload)});
|
|
process.stdout.write(JSON.stringify({{
|
|
queue,
|
|
updates: buildMyWork.filterMyWork(queue, 'update'),
|
|
counts: buildMyWork.countMyWork(queue),
|
|
summary: buildMyWork.summarizeMyWork(queue),
|
|
}}));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert len(output["queue"]) == 2
|
|
assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"]
|
|
assert output["updates"][0]["kind"] == "update"
|
|
assert output["updates"][1]["kind"] == "issue"
|
|
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
|
assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2}
|
|
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
|
|
|
|
|
def test_unread_update_correlation_distinguishes_issue_and_pull_with_same_number():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [{"number": 7, "title": "Issue seven", "repository": "stackchain/api", "url": "https://forge.example/issues/7"}],
|
|
"pull_requests": [{"number": 7, "title": "Pull seven", "repository": "stackchain/api", "url": "https://forge.example/pulls/7"}],
|
|
"notifications": [{
|
|
"id": 42, "number": 7, "title": "Issue seven", "repository": "stackchain/api",
|
|
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment-1",
|
|
}],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
queue = json.loads(result.stdout)
|
|
|
|
issue = next(item for item in queue if item["kind"] == "issue")
|
|
pull = next(item for item in queue if item["kind"] == "pull")
|
|
assert issue["has_update"] is True
|
|
assert issue["url"].endswith("#comment-1")
|
|
assert pull["has_update"] is False
|
|
|
|
|
|
def test_notification_identity_survives_merge_and_acknowledgement_preserves_assigned_work():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [{
|
|
"number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
|
"assignees": ["timmy"], "url": "https://forge.example/issues/7",
|
|
}],
|
|
"notifications": [
|
|
{"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
|
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment"},
|
|
{"id": 43, "number": 8, "title": "Mention", "repository": "stackchain/web",
|
|
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/8"},
|
|
],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const before = buildMyWork({json.dumps(payload)});
|
|
const afterMerged = buildMyWork.acknowledgeNotification(before, 42);
|
|
const afterStandalone = buildMyWork.acknowledgeNotification(before, 43);
|
|
process.stdout.write(JSON.stringify({{before, afterMerged, afterStandalone}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assigned = next(item for item in output["before"] if item["kind"] == "issue")
|
|
assert assigned["notification_id"] == 42
|
|
assert assigned["has_update"] is True
|
|
assert len(output["afterMerged"]) == 2
|
|
acknowledged = next(item for item in output["afterMerged"] if item["kind"] == "issue")
|
|
assert acknowledged["has_update"] is False
|
|
assert "notification_id" not in acknowledged
|
|
assert [item["notification_id"] for item in output["afterStandalone"] if item["has_update"]] == [42]
|
|
|
|
|
|
def test_notification_acknowledger_is_single_flight_and_rolls_back_on_failure():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const original = [{{kind:'update', notification_id:42, has_update:true}}];
|
|
let calls = 0;
|
|
let release;
|
|
const states = [];
|
|
const statuses = [];
|
|
const controller = buildMyWork.createNotificationAcknowledger({{
|
|
markRead: () => {{ calls += 1; return new Promise((resolve, reject) => {{ release = reject; }}); }},
|
|
onItems: items => states.push(items),
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
const first = controller.acknowledge(original, 42);
|
|
const duplicate = controller.acknowledge(original, 42);
|
|
release(new Error('offline'));
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, states, statuses, results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == 1
|
|
assert output["states"] == [[], [{"kind": "update", "notification_id": 42, "has_update": True}]]
|
|
assert output["statuses"] == ["Marking update read…", "Could not mark update read. Retry."]
|
|
assert output["results"] == [False, False]
|
|
|
|
|
|
def test_bulk_notification_acknowledger_deduplicates_and_keeps_partial_failures_retryable():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const original = [
|
|
{{kind:'issue', key:'repo#1', notification_id:42, has_update:true}},
|
|
{{kind:'update', key:'repo#2', notification_id:43, has_update:true}},
|
|
{{kind:'update', key:'repo#3', notification_id:44, has_update:true}},
|
|
];
|
|
let calls = 0;
|
|
let release;
|
|
const states = [];
|
|
const statuses = [];
|
|
const controller = buildMyWork.createBulkNotificationAcknowledger({{
|
|
markRead: ids => {{
|
|
calls += 1;
|
|
return new Promise(resolve => {{ release = () => resolve({{marked:[42,43], failed:[44]}}); }});
|
|
}},
|
|
onItems: items => states.push(items),
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
const first = controller.acknowledge(original, [42, 43, 42, 44]);
|
|
const duplicate = controller.acknowledge(original, [42, 43, 44]);
|
|
release();
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, states, statuses, results,
|
|
retryIds: buildMyWork.notificationIds(states.at(-1)),
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == 1
|
|
assert output["results"] == [
|
|
{"marked": [42, 43], "failed": [44]},
|
|
False,
|
|
]
|
|
assert output["states"] == [[
|
|
{"kind": "issue", "key": "repo#1", "has_update": False},
|
|
{"kind": "update", "key": "repo#3", "notification_id": 44, "has_update": True},
|
|
]]
|
|
assert output["retryIds"] == [44]
|
|
assert output["statuses"] == [
|
|
"Marking 3 updates read…",
|
|
"2 marked read · 1 could not be updated — retry.",
|
|
]
|
|
|
|
|
|
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let calls = 0;
|
|
let release;
|
|
const pages = [];
|
|
const notifications = [];
|
|
const statuses = [];
|
|
const pager = buildMyWork.createNotificationPager({{
|
|
load: page => {{
|
|
calls += 1;
|
|
return new Promise(resolve => {{ release = () => resolve({{
|
|
items: [{{id:50, title:'duplicate'}}, {{id:51, title:'older'}}],
|
|
page, total: 75, has_more: false,
|
|
}}); }});
|
|
}},
|
|
onNotifications: items => notifications.push(items),
|
|
onPagination: page => pages.push(page),
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
pager.reset({{page:1, total:75, has_more:true}});
|
|
const existing = [{{id:50, title:'newer'}}];
|
|
const first = pager.loadMore(existing);
|
|
const duplicate = pager.loadMore(existing);
|
|
release();
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, pages, notifications, statuses, results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == 1
|
|
assert output["notifications"] == [[
|
|
{"id": 50, "title": "newer"},
|
|
{"id": 51, "title": "older"},
|
|
]]
|
|
assert output["pages"][-1] == {"page": 2, "total": 75, "has_more": False}
|
|
assert output["statuses"] == ["Loading older updates…", "75 of 75 unread updates loaded."]
|
|
assert output["results"] == [True, False]
|
|
|
|
|
|
def test_notification_pager_keeps_loaded_updates_and_retries_the_same_page_after_failure():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const states = [];
|
|
const statuses = [];
|
|
const requested = [];
|
|
const pager = buildMyWork.createNotificationPager({{
|
|
load: async page => {{ requested.push(page); throw new Error('offline'); }},
|
|
onNotifications: items => states.push(items),
|
|
onPagination: () => {{}},
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
pager.reset({{page:2, total:125, has_more:true}});
|
|
pager.loadMore([{{id:1}}]).then(result =>
|
|
pager.loadMore([{{id:1}}]).then(retry =>
|
|
process.stdout.write(JSON.stringify({{requested, states, statuses, result, retry}}))
|
|
)
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"requested": [3, 3],
|
|
"states": [],
|
|
"statuses": [
|
|
"Loading older updates…", "Could not load older updates. Retry.",
|
|
"Loading older updates…", "Could not load older updates. Retry.",
|
|
],
|
|
"result": False,
|
|
"retry": False,
|
|
}
|
|
|
|
|
|
def test_notification_reader_marks_current_read_and_opens_next_update():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const original = [
|
|
{{kind:'update', key:'repo#1', notification_id:42, has_update:true, title:'First'}},
|
|
{{kind:'issue', key:'repo#2', notification_id:43, has_update:true, title:'Second'}},
|
|
];
|
|
const loaded = [];
|
|
const opened = [];
|
|
const details = [];
|
|
const states = [];
|
|
const statuses = [];
|
|
const closed = [];
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: async id => {{ loaded.push(id); return {{id, latest_comment:{{body:'Comment ' + id}}}}; }},
|
|
markRead: async id => id,
|
|
onOpen: item => opened.push(item.notification_id),
|
|
onDetail: detail => details.push(detail.id),
|
|
onItems: items => states.push(items),
|
|
onStatus: status => statuses.push(status),
|
|
onClose: () => closed.push(true),
|
|
}});
|
|
reader.open(original[0], original).then(() =>
|
|
reader.markReadAndNext(original).then(result =>
|
|
process.stdout.write(JSON.stringify({{loaded, opened, details, states, statuses, closed, result}}))
|
|
)
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["loaded"] == [42, 43]
|
|
assert output["opened"] == [42, 43]
|
|
assert output["details"] == [42, 43]
|
|
assert output["states"] == [[
|
|
{"kind": "issue", "key": "repo#2", "notification_id": 43,
|
|
"has_update": True, "title": "Second"},
|
|
]]
|
|
assert output["statuses"] == [
|
|
"Loading update…", "Update ready.", "Marking update read…",
|
|
"Loading update…", "Update ready.",
|
|
]
|
|
assert output["closed"] == []
|
|
assert output["result"]["next"]["notification_id"] == 43
|
|
|
|
|
|
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const item = {{kind:'update', notification_id:42, has_update:true}};
|
|
const events = [];
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: async () => {{ throw new Error('offline'); }},
|
|
markRead: async () => {{}}, onOpen: () => events.push('open'),
|
|
onDetail: () => events.push('detail'), onItems: () => events.push('items'),
|
|
onStatus: status => events.push(status), onClose: () => events.push('close'),
|
|
}});
|
|
reader.open(item, [item]).then(result =>
|
|
process.stdout.write(JSON.stringify({{result, events}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"result": False,
|
|
"events": [
|
|
"open", "Loading update…",
|
|
"Could not load update. Retry or open it in Gitea.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_notification_reader_does_not_advance_or_remove_item_when_mark_read_fails():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const item = {{kind:'update', notification_id:42, has_update:true}};
|
|
const events = [];
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: async id => ({{id}}), markRead: async () => {{ throw new Error('offline'); }},
|
|
onOpen: () => events.push('open'), onDetail: () => events.push('detail'),
|
|
onItems: () => events.push('items'), onStatus: status => events.push(status),
|
|
onClose: () => events.push('close'),
|
|
}});
|
|
reader.open(item, [item]).then(() =>
|
|
reader.markReadAndNext([item]).then(result =>
|
|
process.stdout.write(JSON.stringify({{result, events}}))
|
|
)
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"result": False,
|
|
"events": [
|
|
"open", "Loading update…", "detail", "Update ready.",
|
|
"Marking update read…", "Could not mark update read. Retry.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = [
|
|
{{kind:'update', notification_id:42, has_update:true}},
|
|
{{kind:'update', notification_id:43, has_update:true}},
|
|
];
|
|
const opened = [];
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: async id => ({{id}}), markRead: async () => {{}},
|
|
onOpen: item => opened.push(item.notification_id), onDetail: () => {{}},
|
|
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
|
}});
|
|
reader.open(items[1], items).then(() => reader.markReadAndNext(items)).then(result =>
|
|
process.stdout.write(JSON.stringify({{opened, next:result.next.notification_id}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {"opened": [43, 42], "next": 42}
|
|
|
|
|
|
def test_notification_reader_closes_and_announces_when_final_update_is_cleared():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const item = {{kind:'update', notification_id:42, has_update:true}};
|
|
const events = [];
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: async id => ({{id}}), markRead: async () => {{}},
|
|
onOpen: () => {{}}, onDetail: () => {{}}, onItems: items => events.push(['items', items]),
|
|
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
|
}});
|
|
reader.open(item, [item]).then(() => {{
|
|
events.length = 0;
|
|
return reader.markReadAndNext([item]);
|
|
}}).then(result => process.stdout.write(JSON.stringify({{result, events}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["result"] == {"items": [], "next": None}
|
|
assert output["events"] == [
|
|
["status", "Marking update read…"],
|
|
["items", []],
|
|
["close"],
|
|
["status", "Inbox cleared."],
|
|
]
|
|
|
|
|
|
@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 'data-work-filter="update"' 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_unread_cards_offer_accessible_mobile_mark_read_without_nested_actions():
|
|
html = await dashboard()
|
|
|
|
assert '.mark-update-read' in html and 'min-height:44px' in html
|
|
assert 'data-notification-id' in html
|
|
assert 'Unread update' in html
|
|
assert 'id="my-work-action-status"' in html and 'aria-live="assertive"' in html
|
|
assert "createNotificationAcknowledger" in html
|
|
assert "method: 'PATCH'" in html
|
|
assert "api/v1/notifications/" in html
|
|
assert '<a class="my-work-card"' not in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
|
|
html = await dashboard()
|
|
|
|
assert 'id="update-sheet"' in html and 'aria-modal="true"' in html
|
|
assert 'class="read-update"' in html
|
|
assert 'id="keep-update-unread"' in html
|
|
assert 'id="mark-update-read-next"' in html
|
|
assert 'id="retry-update-load"' in html
|
|
assert 'id="update-comment-body"' in html
|
|
assert 'id="update-subject-body"' in html
|
|
assert 'id="open-update-gitea"' in html
|
|
assert '.update-sheet-panel { width:min(560px,100%);' in html
|
|
assert '.update-sheet-content { overflow-wrap:anywhere;' in html
|
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
|
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
|
assert "createNotificationReader" in html
|
|
assert "api/v1/notifications/" in html
|
|
assert "notificationReader.markReadAndNext(lastMyWork)" in html
|
|
|
|
|
|
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem: key => values.has(key) ? values.get(key) : null,
|
|
setItem: (key, value) => values.set(key, value),
|
|
removeItem: key => values.delete(key),
|
|
}};
|
|
const statuses = [];
|
|
let calls = 0;
|
|
let fail = true;
|
|
const replier = buildMyWork.createNotificationReplier({{
|
|
storage,
|
|
post: async (id, body) => {{
|
|
calls += 1;
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
if (fail) throw new Error('offline');
|
|
return {{id:91, url:'https://forge.example/comment/91'}};
|
|
}},
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
const item = {{notification_id:42}};
|
|
replier.saveDraft(item, 'Please retry.');
|
|
Promise.all([replier.submit(item, 'Please retry.'), replier.submit(item, 'Please retry.')])
|
|
.then(async first => {{
|
|
const afterFailure = replier.loadDraft(item);
|
|
fail = false;
|
|
const success = await replier.submit(item, afterFailure);
|
|
process.stdout.write(JSON.stringify({{
|
|
first, afterFailure, success, afterSuccess:replier.loadDraft(item), calls, statuses,
|
|
}}));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["first"] == [False, False]
|
|
assert output["afterFailure"] == "Please retry."
|
|
assert output["success"]["id"] == 91
|
|
assert output["afterSuccess"] == ""
|
|
assert output["calls"] == 2
|
|
assert output["statuses"] == [
|
|
"Sending reply…",
|
|
"Could not send reply. Your draft is safe; retry.",
|
|
"Sending reply…",
|
|
"Reply posted. You can mark this update read when ready.",
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
|
|
html = await dashboard()
|
|
|
|
assert 'id="update-reply"' in html
|
|
assert 'maxlength="10000"' in html
|
|
assert 'id="send-update-reply"' in html
|
|
assert 'id="update-reply-status"' in html
|
|
assert 'aria-live="assertive"' in html
|
|
assert '.update-reply textarea { width:100%;' in html
|
|
assert '.update-reply button { min-height:44px;' in html
|
|
assert '.update-sheet-header button { min-height:44px;' in html
|
|
assert 'createNotificationReplier' in html
|
|
assert "method: 'POST'" in html
|
|
assert "'/reply'" in html
|
|
assert "notificationReplier.loadDraft(item)" in html
|
|
assert "notificationReplier.saveDraft(selectedUpdate" in html
|
|
assert "notificationReplier.submit(selectedUpdate" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
|
html = await dashboard()
|
|
|
|
assert 'id="bulk-mark-read"' in html
|
|
assert 'id="bulk-mark-read-bar"' in html
|
|
assert 'class="my-work-bulk"' in html
|
|
assert '.my-work-bulk { position:sticky;' in html
|
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
|
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
|
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
|
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
|
assert "const ids = allIds.slice(0, 50)" in html
|
|
assert "createBulkNotificationAcknowledger" in html
|
|
assert "api/v1/notifications/read" in html
|
|
assert "body: JSON.stringify({ ids })" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
|
|
html = await dashboard()
|
|
|
|
assert 'id="notification-page-status"' in html
|
|
assert 'id="load-more-notifications"' in html
|
|
assert '.load-more-notifications' in html and 'min-height:44px' in html
|
|
assert "createNotificationPager" in html
|
|
assert "api/v1/notifications?page=" in html
|
|
assert "snapshot.notification_pagination" in html
|
|
assert "notificationPager.loadMore(lastNotifications)" in html
|
|
assert "Math.min(lastNotifications.length, 50)" 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 'data-work-count="update"' in html
|
|
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" 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_reserves_window_before_copy_and_reports_blocked_popup():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const events = [];
|
|
let finishCopy;
|
|
const reserved = {{ location: {{ href: '' }}, close: () => events.push('close') }};
|
|
const success = reviewSheet.copyAndContinue({{
|
|
text: 'feedback', url: 'https://forge.example/pulls/7',
|
|
copy: text => new Promise(resolve => {{ events.push('copy:' + text); finishCopy = resolve; }}),
|
|
open: () => {{ events.push('reserve'); return reserved; }},
|
|
fallback: text => events.push('fallback:' + text),
|
|
}});
|
|
const beforeCopySettles = events.slice();
|
|
finishCopy();
|
|
const blockedEvents = [];
|
|
const blocked = reviewSheet.copyAndContinue({{
|
|
text: 'copied', url: 'https://forge.example/pulls/8',
|
|
copy: async () => blockedEvents.push('copy'),
|
|
open: () => {{ blockedEvents.push('reserve'); return null; }},
|
|
fallback: text => blockedEvents.push('fallback:' + text),
|
|
}});
|
|
Promise.all([success, blocked]).then(results => process.stdout.write(JSON.stringify({{
|
|
beforeCopySettles, events, blockedEvents, destination: reserved.location.href, results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"beforeCopySettles": ["reserve", "copy:feedback"],
|
|
"events": ["reserve", "copy:feedback"],
|
|
"blockedEvents": ["reserve", "copy", "fallback:copied"],
|
|
"destination": "https://forge.example/pulls/7",
|
|
"results": [
|
|
{"copied": True, "opened": True},
|
|
{"copied": True, "opened": False},
|
|
],
|
|
}
|
|
|
|
|
|
def test_review_handoff_closes_reserved_window_when_copy_fails():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const events = [];
|
|
reviewSheet.copyAndContinue({{
|
|
text: 'keep me', url: 'https://forge.example/pulls/8',
|
|
copy: async () => {{ throw new Error('denied'); }},
|
|
open: () => ({{ close: () => events.push('close') }}),
|
|
fallback: text => events.push('fallback:' + text),
|
|
}}).then(result => process.stdout.write(JSON.stringify({{ events, result }})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"events": ["close", "fallback:keep me"],
|
|
"result": {"copied": False, "opened": 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 'id="review-handoff-link"' in html
|
|
assert '.review-handoff-link' in html and 'min-height:44px' in html
|
|
assert '.review-handoff-link[hidden]' in html and 'display:none' 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('about:blank', '_blank')" in html
|
|
assert "handoffWindow.opener = null" 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
|