7815 lines
321 KiB
Python
7815 lines
321 KiB
Python
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_empty_all_work_queue_offers_find_and_create_actions():
|
|
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
|
source = await dashboard()
|
|
|
|
assert 'id="empty-work-start"' in markup
|
|
assert 'Ready for something new?' in markup
|
|
assert 'id="empty-work-find"' in markup
|
|
assert 'id="empty-work-create"' in markup
|
|
assert "qs('#empty-work-find').addEventListener('click'" in source
|
|
assert "qs('#find-work').click()" in source
|
|
assert "qs('#empty-work-create').addEventListener('click'" in source
|
|
assert "qs('#new-issue').click()" in source
|
|
assert "emptyWorkStart.hidden = !showEmptyStart" in source
|
|
|
|
|
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
|
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
|
|
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
|
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
|
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
|
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
|
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.js"
|
|
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
|
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
|
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
|
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
|
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
|
COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
|
|
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
|
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
|
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
|
AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js"
|
|
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
|
|
|
|
|
def test_filed_follow_up_target_preserves_queue_order_and_selects_update_reader():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const choose = buildMyWork.filedFollowUpTarget;
|
|
const available = typeof choose === 'function';
|
|
const quiet = {{key:'repo#1', is_filed:true, has_update:false}};
|
|
const unread = {{key:'repo#2', is_filed:true, has_update:true, notification_id:22}};
|
|
const unrelated = {{key:'repo#3', is_filed:false, has_update:true}};
|
|
const results = available ? {{
|
|
first:choose([unrelated, unread, quiet]),
|
|
fallback:choose([unrelated, quiet, unread]),
|
|
empty:choose([unrelated]),
|
|
}} : null;
|
|
process.stdout.write(JSON.stringify({{available, results}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"available": True,
|
|
"results": {
|
|
"first": {"kind": "update", "item": {"key": "repo#2", "is_filed": True, "has_update": True, "notification_id": 22}},
|
|
"fallback": {"kind": "issue", "item": {"key": "repo#1", "is_filed": True, "has_update": False}},
|
|
"empty": None,
|
|
},
|
|
}
|
|
|
|
|
|
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [
|
|
{"number": 1, "title": "Critical incident", "repository": "stackchain/api",
|
|
"labels": ["P0"], "assignees": [], "updated_at": "2026-08-01T10:00:00Z"},
|
|
{"number": 2, "title": "Overdue assignment", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z",
|
|
"updated_at": "2026-08-05T10:00:00Z"},
|
|
{"number": 3, "title": "Assigned follow-up", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
|
|
],
|
|
"pull_requests": [
|
|
{"number": 4, "title": "Requested review", "repository": "stackchain/web",
|
|
"labels": [], "assignees": [], "work_reasons": ["review_requested"],
|
|
"updated_at": "2026-08-02T10:00:00Z"},
|
|
],
|
|
"notifications": [
|
|
{"id": 11, "number": 1, "repository": "stackchain/api", "unread": True,
|
|
"subject_type": "Issue", "url": "https://forge.example/api/issues/1"},
|
|
{"id": 12, "number": 2, "repository": "stackchain/api", "unread": True,
|
|
"subject_type": "Issue", "url": "https://forge.example/api/issues/2"},
|
|
{"id": 13, "number": 3, "repository": "stackchain/api", "unread": True,
|
|
"subject_type": "Issue", "url": "https://forge.example/api/issues/3"},
|
|
{"id": 14, "number": 4, "repository": "stackchain/web", "unread": True,
|
|
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/4"},
|
|
{"id": 15, "number": 5, "repository": "stackchain/web", "unread": True,
|
|
"subject_type": "Issue", "title": "Newest routine update",
|
|
"updated_at": "2026-08-07T11:00:00Z", "url": "https://forge.example/web/issues/5"},
|
|
],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const updates = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'))
|
|
.filter(item => item.has_update)
|
|
.map(item => ({{title:item.title, update_reason:item.update_reason}}));
|
|
process.stdout.write(JSON.stringify(updates));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == [
|
|
{"title": "Critical incident", "update_reason": "Critical"},
|
|
{"title": "Requested review", "update_reason": "Review requested"},
|
|
{"title": "Overdue assignment", "update_reason": "Overdue"},
|
|
{"title": "Assigned follow-up", "update_reason": "Assigned to you"},
|
|
{"title": "Newest routine update", "update_reason": ""},
|
|
]
|
|
|
|
|
|
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
|
let release;
|
|
let discoveries = 0;
|
|
let opens = 0;
|
|
const messages = [];
|
|
const launcher = createLauncher({{
|
|
selectUpdates: () => {{}},
|
|
discover: () => {{ discoveries += 1; return new Promise(resolve => {{ release = resolve; }}); }},
|
|
isUpdatesSelected: () => true,
|
|
hasMore: () => false,
|
|
hasCheckpoint: () => false,
|
|
resume: () => {{ throw new Error('must start'); }},
|
|
start: () => {{ opens += 1; return true; }},
|
|
announce: message => messages.push(message),
|
|
}});
|
|
const first = launcher.open();
|
|
const second = launcher.open();
|
|
Promise.resolve().then(() => {{
|
|
const before = {{discoveries, opens, same:first === second}};
|
|
release(true);
|
|
return Promise.all([first, second]).then(results =>
|
|
process.stdout.write(JSON.stringify({{before, discoveries, opens, results, messages}}))
|
|
);
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"before": {"discoveries": 1, "opens": 0, "same": True},
|
|
"discoveries": 1,
|
|
"opens": 1,
|
|
"results": ["opened", "opened"],
|
|
"messages": ["Checking all unread updates…"],
|
|
}
|
|
|
|
|
|
def test_update_triage_launch_preserves_checkpoint_when_discovery_fails():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
|
const messages = [];
|
|
let resumed = 0;
|
|
const launcher = createLauncher({{
|
|
selectUpdates: () => {{}},
|
|
discover: async () => false,
|
|
isUpdatesSelected: () => true,
|
|
hasMore: () => true,
|
|
hasCheckpoint: () => true,
|
|
resume: () => {{ resumed += 1; return true; }},
|
|
start: () => true,
|
|
announce: message => messages.push(message),
|
|
}});
|
|
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, resumed, messages}})));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"result": "incomplete",
|
|
"resumed": 0,
|
|
"messages": [
|
|
"Checking all unread updates…",
|
|
"Updates check paused. Retry to check older unread updates.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_update_triage_launch_applies_atomic_snapshot_before_starting():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
|
const events = [];
|
|
const snapshot = {{items:[{{id:1}},{{id:51}}],total:2,complete:true}};
|
|
const launcher = createLauncher({{
|
|
selectUpdates: () => events.push('selected'),
|
|
discover: async () => snapshot,
|
|
applySnapshot: value => events.push(['applied', value.items.map(item => item.id)]),
|
|
isUpdatesSelected: () => true,
|
|
hasMore: () => false,
|
|
hasCheckpoint: () => false,
|
|
resume: () => {{ throw new Error('must start'); }},
|
|
start: () => {{ events.push('started'); return true; }},
|
|
announce: () => {{}},
|
|
}});
|
|
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, events}})));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"result": "opened",
|
|
"events": ["selected", ["applied", [1, 51]], "started"],
|
|
}
|
|
|
|
|
|
def test_notification_pager_load_all_retries_from_failed_page_without_duplicates():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let items = [{{id:1}}];
|
|
let fail = true;
|
|
const requested = [];
|
|
const pager = buildMyWork.createNotificationPager({{
|
|
load: async page => {{
|
|
requested.push(page);
|
|
if (page === 2 && fail) {{ fail = false; throw new Error('offline'); }}
|
|
return page === 2 ? {{page:2,total:101,has_more:true,items:[{{id:1}},{{id:51}}]}} :
|
|
{{page:3,total:101,has_more:false,items:[{{id:101}}]}};
|
|
}},
|
|
onNotifications: value => {{ items = value; }},
|
|
onPagination: () => {{}},
|
|
onStatus: () => {{}},
|
|
}});
|
|
pager.reset({{page:1,total:101,has_more:true}});
|
|
pager.loadAll(() => items).then(firstResult => pager.loadAll(() => items).then(secondResult =>
|
|
process.stdout.write(JSON.stringify({{firstResult, secondResult, requested, ids:items.map(item => item.id)}}))
|
|
));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"firstResult": False,
|
|
"secondResult": True,
|
|
"requested": [2, 2, 3],
|
|
"ids": [1, 51, 101],
|
|
}
|
|
|
|
|
|
def test_agenda_session_launch_waits_for_complete_single_flight_discovery():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
|
|
let finishDiscovery;
|
|
let discoveries = 0;
|
|
let opens = 0;
|
|
let queue = 'agenda';
|
|
const launcher = createLauncher({{
|
|
selectAgenda: () => {{ queue = 'agenda'; }},
|
|
discover: () => {{
|
|
discoveries += 1;
|
|
return new Promise(resolve => {{ finishDiscovery = resolve; }});
|
|
}},
|
|
isAgendaSelected: () => queue === 'agenda',
|
|
hasMore: () => false,
|
|
hasCheckpoint: () => true,
|
|
resume: () => {{ opens += 1; return true; }},
|
|
start: () => {{ throw new Error('must resume'); }},
|
|
announce: () => {{}},
|
|
}});
|
|
const first = launcher.open();
|
|
const second = launcher.open();
|
|
Promise.resolve().then(() => {{
|
|
const before = {{discoveries, opens, same:first === second}};
|
|
finishDiscovery(true);
|
|
return Promise.all([first, second]).then(results => {{
|
|
process.stdout.write(JSON.stringify({{before, results, discoveries, opens}}));
|
|
}});
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"before": {"discoveries": 1, "opens": 0, "same": True},
|
|
"results": ["opened", "opened"],
|
|
"discoveries": 1,
|
|
"opens": 1,
|
|
}
|
|
|
|
|
|
def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes():
|
|
script = f"""
|
|
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
|
|
async function scenario(mode) {{
|
|
let queue = 'agenda';
|
|
let opens = 0;
|
|
const messages = [];
|
|
const launcher = createLauncher({{
|
|
selectAgenda: () => {{ queue = 'agenda'; }},
|
|
discover: async () => {{
|
|
if (mode === 'leave') queue = 'today';
|
|
if (mode === 'throw') throw new Error('offline');
|
|
return mode !== 'incomplete';
|
|
}},
|
|
isAgendaSelected: () => queue === 'agenda',
|
|
hasMore: () => mode === 'incomplete',
|
|
hasCheckpoint: () => true,
|
|
resume: () => {{ opens += 1; return true; }},
|
|
start: () => {{ opens += 1; return true; }},
|
|
announce: message => messages.push(message),
|
|
}});
|
|
return {{result:await launcher.open(), opens, messages}};
|
|
}}
|
|
Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results =>
|
|
process.stdout.write(JSON.stringify(results))
|
|
);
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == [
|
|
{"result": "incomplete", "opens": 0, "messages": [
|
|
"Agenda check paused. Retry to check older assigned deadlines."
|
|
]},
|
|
{"result": "incomplete", "opens": 0, "messages": [
|
|
"Agenda check paused. Retry to check older assigned deadlines."
|
|
]},
|
|
{"result": "cancelled", "opens": 0, "messages": []},
|
|
]
|
|
|
|
|
|
def test_protect_today_route_is_an_explicit_agenda_action():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today')));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"kind": "queue", "filter": "agenda", "action": "protect-today"
|
|
}
|
|
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
|
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
|
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
|
|
|
|
|
def test_work_selection_selects_matching_items_in_order_with_an_explicit_cap():
|
|
script = f"""
|
|
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
|
const selection = createWorkSelection({{limit:3}});
|
|
selection.start();
|
|
selection.select({{kind:'issue', repository:'stackchain/api', number:1}});
|
|
const result = selection.selectMany([
|
|
{{kind:'issue', repository:'stackchain/api', number:1}},
|
|
{{kind:'pull', repository:'stackchain/web', number:2}},
|
|
{{kind:'issue', repository:'stackchain/app', number:3}},
|
|
{{kind:'issue', repository:'stackchain/overflow', number:4}},
|
|
]);
|
|
process.stdout.write(JSON.stringify({{result, snapshot:selection.snapshot()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output == {
|
|
"result": {"status": "limit", "added": 2, "count": 3, "limit": 3},
|
|
"snapshot": {
|
|
"active": True,
|
|
"count": 3,
|
|
"ids": [
|
|
"issue:stackchain/api:1:",
|
|
"pull:stackchain/web:2:",
|
|
"issue:stackchain/app:3:",
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def test_work_selection_clears_matches_without_leaving_selection_mode():
|
|
script = f"""
|
|
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
|
const selection = createWorkSelection();
|
|
selection.start();
|
|
selection.selectMany([
|
|
{{kind:'issue', repository:'stackchain/api', number:1}},
|
|
{{kind:'pull', repository:'stackchain/web', number:2}},
|
|
]);
|
|
process.stdout.write(JSON.stringify(selection.clear()));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {"active": True, "count": 0, "ids": []}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches():
|
|
html = await dashboard()
|
|
|
|
assert 'id="select-matching-work"' in html
|
|
assert 'id="clear-work-selection"' in html
|
|
assert "workSelection.selectMany(visible)" in html
|
|
assert "workSelection.clear()" in html
|
|
assert "matches selected" in html
|
|
assert ".selection-scope-actions button { min-height:44px;" in html
|
|
assert "max-width:100%" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_issue_sheet_formats_due_date_as_a_calendar_day_without_local_timestamp_conversion():
|
|
source = await dashboard()
|
|
|
|
assert "formatCalendarDueDate(detail.due_date)" in source
|
|
assert "new Date(detail.due_date).toLocaleDateString()" not in source
|
|
|
|
|
|
def test_queue_finder_matches_repository_number_and_title_without_reordering():
|
|
script = f"""
|
|
const work = require({json.dumps(str(MY_WORK))});
|
|
const items = [
|
|
{{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}},
|
|
{{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}},
|
|
{{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}},
|
|
];
|
|
process.stdout.write(JSON.stringify({{
|
|
repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key),
|
|
number:work.findQueueItems(items, '#42').map(item => item.key),
|
|
title:work.findQueueItems(items, 'mobile queue').map(item => item.key),
|
|
clear:work.findQueueItems(items, ' ').map(item => item.key),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"repo": ["stackchain/api#42"],
|
|
"number": ["stackchain/api#42", "other/repo#42"],
|
|
"title": ["stackchain/web#7"],
|
|
"clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"],
|
|
}
|
|
|
|
|
|
def test_mobile_agenda_groups_assigned_deadlines_in_local_seven_day_horizon():
|
|
script = f"""
|
|
const work = require({json.dumps(str(MY_WORK))});
|
|
const now = new Date('2026-08-12T12:00:00');
|
|
const items = [
|
|
{{kind:'issue', key:'o/r#5', repository:'o/r', number:5, is_assigned:true, due_date:'2026-08-11T18:00:00'}},
|
|
{{kind:'issue', key:'o/r#3', repository:'o/r', number:3, is_assigned:true, due_date:'2026-08-12T17:00:00'}},
|
|
{{kind:'issue', key:'a/r#9', repository:'a/r', number:9, is_assigned:true, due_date:'2026-08-13T09:00:00'}},
|
|
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-18T09:00:00'}},
|
|
{{kind:'issue', key:'a/r#1', repository:'a/r', number:1, is_assigned:true, due_date:'2026-08-20T09:00:00'}},
|
|
{{kind:'issue', key:'a/r#7', repository:'a/r', number:7, is_assigned:false, due_date:'2026-08-13T08:00:00'}},
|
|
{{kind:'pull', key:'a/r#8', repository:'a/r', number:8, is_assigned:true, due_date:'2026-08-13T08:00:00'}},
|
|
{{kind:'issue', key:'a/r#6', repository:'a/r', number:6, is_assigned:true}},
|
|
];
|
|
process.stdout.write(JSON.stringify(work.agendaMyWork(items, now).map(item => [item.key, item.agenda_group])));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == [
|
|
["o/r#5", "Overdue"], ["o/r#3", "Today"],
|
|
["a/r#9", "Tomorrow"], ["a/r#2", "Next 7 days"],
|
|
]
|
|
|
|
|
|
def test_mobile_agenda_sorts_equal_deadlines_by_repository_and_number():
|
|
script = f"""
|
|
const work = require({json.dumps(str(MY_WORK))});
|
|
const items = [
|
|
{{kind:'issue', key:'z/r#1', repository:'z/r', number:1, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
|
|
{{kind:'issue', key:'a/r#10', repository:'a/r', number:10, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
|
|
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
|
|
];
|
|
process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-12T12:00:00')).map(item => item.key)));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"]
|
|
|
|
|
|
def test_mobile_agenda_runs_as_an_ordered_non_durable_work_session():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
|
|
const checkpoint = {{
|
|
read:() => null,
|
|
save:() => {{ throw new Error('Agenda must not save Today'); }},
|
|
clear:() => {{ throw new Error('Agenda must not clear Today'); }},
|
|
}};
|
|
const active = [
|
|
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow',is_assigned:true,due_date:'2026-08-14'}},
|
|
{{kind:'issue',repository:'o/r',number:1,title:'Overdue',is_assigned:true,due_date:'2026-08-12'}},
|
|
{{kind:'issue',repository:'o/r',number:2,title:'Today',is_assigned:true,due_date:'2026-08-13'}},
|
|
{{kind:'issue',repository:'o/r',number:4,title:'No deadline',is_assigned:true}},
|
|
];
|
|
const opened = [];
|
|
let finished = 0;
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems:() => buildMyWork.agendaMyWork(active, new Date('2026-08-13T12:00:00')),
|
|
getFilter:() => 'all', checkpoint, checkpointEnabled:() => false,
|
|
onOpen:item => opened.push(item.title), onProgress:() => {{}},
|
|
onFinish:() => {{ finished += 1; }},
|
|
}});
|
|
const started = session.start();
|
|
session.next();
|
|
session.next();
|
|
const completed = session.next();
|
|
process.stdout.write(JSON.stringify({{
|
|
started, completed, opened, finished, active:session.active(), today:values.get('stackchain.today-session.v1'),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"started": True,
|
|
"completed": False,
|
|
"opened": ["Overdue", "Today", "Tomorrow"],
|
|
"finished": 1,
|
|
"active": False,
|
|
"today": "preserve-today",
|
|
}
|
|
|
|
|
|
def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key, value) => values.set(key, value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let login = 'timmy';
|
|
const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
|
storage, getLogin:() => login, key:'stackchain.agenda-session.v1',
|
|
}});
|
|
const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
|
storage, getLogin:() => login, key:'stackchain.today-session.v1',
|
|
}});
|
|
let mode = 'agenda';
|
|
let items = [
|
|
{{kind:'issue',repository:'o/r',number:1,title:'Overdue'}},
|
|
{{kind:'issue',repository:'o/r',number:2,title:'Today'}},
|
|
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}},
|
|
];
|
|
const opened = [];
|
|
const makeSession = () => buildMyWork.createWorkSession({{
|
|
getItems:() => items, getFilter:() => 'all',
|
|
checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint,
|
|
checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today',
|
|
onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}},
|
|
}});
|
|
const first = makeSession();
|
|
first.start();
|
|
first.next();
|
|
const saved = JSON.parse(values.get('stackchain.agenda-session.v1'));
|
|
const todayWhileRunning = first.checkpointed();
|
|
login = 'alexander';
|
|
const hiddenFromOtherAccount = makeSession().resumable();
|
|
login = 'timmy';
|
|
items = items.filter(item => item.number !== 2);
|
|
const resumedSession = makeSession();
|
|
const resumed = resumedSession.resume();
|
|
resumedSession.end();
|
|
process.stdout.write(JSON.stringify({{
|
|
saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened,
|
|
agendaCleared:!values.has('stackchain.agenda-session.v1'),
|
|
today:values.get('stackchain.today-session.v1'),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
state = json.loads(result.stdout)
|
|
assert state["saved"] == {
|
|
"version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1,
|
|
}
|
|
assert state["todayWhileRunning"] is False
|
|
assert state["hiddenFromOtherAccount"] is False
|
|
assert state["resumed"] is True
|
|
assert state["opened"] == ["Overdue", "Today", "Tomorrow"]
|
|
assert state["agendaCleared"] is True
|
|
assert state["today"] == "preserve-today"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/agenda-session-launcher.js"></script>' in html
|
|
assert "openAgenda: openAgendaSession" in html
|
|
assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html
|
|
assert "discover: completeAgendaIssues" in html
|
|
assert "hasMore: () => Boolean(workPagination.issue?.has_more)" in html
|
|
assert "key: 'stackchain.agenda-session.v1'" in html
|
|
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
|
|
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
|
|
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
|
|
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
|
|
assert "window.location.hash = '#/my-work/agenda'" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
|
|
html = await dashboard()
|
|
|
|
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html
|
|
assert "selectedWorkFilter === 'agenda' ? 'all'" in html
|
|
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
|
|
assert "const sessionItems = workSession.items()" in html
|
|
assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html
|
|
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
|
|
|
|
|
|
@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
|
|
def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name):
|
|
script = f"""
|
|
const work = require({json.dumps(str(MY_WORK))});
|
|
const now = new Date(2026, 7, 13, 12, 0, 0);
|
|
const built = work({{
|
|
user:{{login:'timmy'}}, notifications:[], pull_requests:[],
|
|
issues:[{{number:1,title:'Ship',repository:'o/r',labels:[],assignees:['timmy'],due_date:'2026-08-13T23:59:59Z'}}],
|
|
}}, now);
|
|
process.stdout.write(JSON.stringify({{
|
|
dueLabel:built[0].due_label,
|
|
agenda:work.agendaMyWork(built, now).map(item => [item.key, item.agenda_group]),
|
|
}}));
|
|
"""
|
|
environment = {**os.environ, "TZ": timezone_name}
|
|
result = subprocess.run(
|
|
["node", "-e", script], capture_output=True, text=True, env=environment
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"dueLabel": "Due today",
|
|
"agenda": [["o/r#1", "Today"]],
|
|
}
|
|
|
|
|
|
def test_agenda_pager_loads_every_issue_page_single_flight_and_retries_failed_page():
|
|
script = f"""
|
|
const work = require({json.dumps(str(MY_WORK))});
|
|
const calls = [];
|
|
let failPage = 3;
|
|
let items = [{{id:1, title:'first'}}];
|
|
let pagination = {{issue:{{page:1,total:4,has_more:true}}}};
|
|
const pager = work.createWorkPager({{
|
|
load: async (stream, page) => {{
|
|
calls.push(page);
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
if (page === failPage) throw new Error('offline');
|
|
return {{page,total:4,has_more:page < 4,items:[{{id:page,title:'page '+page}}]}};
|
|
}},
|
|
onItems: (_stream, next) => {{ items = next; }},
|
|
onPagination: next => {{ pagination = next; }},
|
|
onStatus: () => {{}},
|
|
}});
|
|
pager.reset(pagination);
|
|
async function run() {{
|
|
const first = pager.loadAll('issue', () => items);
|
|
const duplicate = pager.loadAll('issue', () => items);
|
|
const failed = await first;
|
|
const samePromise = first === duplicate;
|
|
failPage = 0;
|
|
const retried = await pager.loadAll('issue', () => items);
|
|
process.stdout.write(JSON.stringify({{
|
|
failed, samePromise, retried, calls, ids:items.map(item => item.id), pagination,
|
|
}}));
|
|
}}
|
|
run();
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"failed": False,
|
|
"samePromise": True,
|
|
"retried": True,
|
|
"calls": [2, 3, 3, 4],
|
|
"ids": [1, 2, 3, 4],
|
|
"pagination": {"issue": {"page": 4, "total": 4, "has_more": False}},
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_state():
|
|
html = await dashboard()
|
|
|
|
assert "workPager.loadAll('issue'" in html
|
|
assert "Checking all assigned deadlines…" in html
|
|
assert "Agenda check paused. Retry to check older assigned deadlines." in html
|
|
assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
|
html = await dashboard()
|
|
|
|
assert '<form class="queue-finder" id="queue-finder" role="search">' in html
|
|
assert 'id="queue-find-input"' in html
|
|
assert 'aria-label="Find in active work queue"' in html
|
|
assert 'id="clear-queue-find"' in html
|
|
assert 'id="search-older-work"' in html
|
|
assert 'id="queue-find-status" role="status" aria-live="polite"' in html
|
|
assert '.queue-finder input, .queue-finder button { min-height:44px;' in html
|
|
mobile = html.index('@media (max-width: 600px)')
|
|
assert '.queue-finder { position:sticky;' in html[mobile:]
|
|
assert "findQueueItems(queueItems, queueFindQuery)" in html
|
|
|
|
|
|
def test_today_batch_admission_is_ordered_deduplicated_and_atomic():
|
|
script = f"""
|
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
|
function storage(fail = false) {{
|
|
const values = new Map();
|
|
return {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem(key, value) {{ if (fail) throw new Error('quota'); values.set(key, value); }},
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
}}
|
|
const first = {{kind:'issue',repository:'o/r',number:1}};
|
|
const second = {{kind:'pull',repository:'o/r',number:2}};
|
|
const third = {{kind:'issue',repository:'o/r',number:3}};
|
|
const today = createTodayWork({{storage:storage(),getLogin:()=> 'timmy',limit:3}});
|
|
const admitted = today.addMany([first, first, second]);
|
|
const existing = today.addMany([second, third]);
|
|
const full = today.addMany([{{kind:'issue',repository:'o/r',number:4}}]);
|
|
const broken = createTodayWork({{storage:storage(true),getLogin:()=> 'timmy'}});
|
|
process.stdout.write(JSON.stringify({{
|
|
admitted, existing, full, ids:today.read(), broken:broken.addMany([first, second]), brokenIds:broken.read(),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"admitted": {"status": "added", "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
|
"existing": {"status": "added", "ids": ["issue:o/r:3:"]},
|
|
"full": {"status": "full", "ids": []},
|
|
"ids": ["issue:o/r:1:", "pull:o/r:2:", "issue:o/r:3:"],
|
|
"broken": {"status": "unavailable", "ids": []},
|
|
"brokenIds": [],
|
|
}
|
|
|
|
|
|
def test_work_selection_tracks_cross_kind_items_and_retains_failures():
|
|
script = f"""
|
|
const create = require({json.dumps(str(WORK_SELECTION))});
|
|
const selection = create({{limit:3}});
|
|
const issue = {{kind:'issue',repository:'o/r',number:1,title:'One'}};
|
|
const pull = {{kind:'pull',repository:'o/r',number:2,title:'Two'}};
|
|
selection.start(); selection.select(issue); selection.select(pull);
|
|
const before = selection.snapshot();
|
|
selection.retain([pull]);
|
|
process.stdout.write(JSON.stringify({{before, after:selection.snapshot(), issueId:selection.identity(issue)}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"before": {"active": True, "count": 2, "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
|
"after": {"active": True, "count": 1, "ids": ["pull:o/r:2:"]},
|
|
"issueId": "issue:o/r:1:",
|
|
}
|
|
|
|
|
|
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
|
script = f"""
|
|
const createCardPlanning = require({json.dumps(str(CARD_PLANNING))});
|
|
function disclosure(name) {{
|
|
const listeners = {{}};
|
|
const attrs = {{}};
|
|
const summary = {{
|
|
focusCount: 0,
|
|
setAttribute(key, value) {{ attrs[key] = value; }},
|
|
focus() {{ this.focusCount += 1; }},
|
|
}};
|
|
return {{
|
|
name, open: false, dataset: {{}}, summary, attrs,
|
|
querySelector(selector) {{ return selector === 'summary' ? summary : null; }},
|
|
addEventListener(type, listener) {{ listeners[type] = listener; }},
|
|
fire(type, event = {{}}) {{ listeners[type](event); }},
|
|
}};
|
|
}}
|
|
const first = disclosure('first');
|
|
const second = disclosure('second');
|
|
const controller = createCardPlanning({{
|
|
querySelectorAll() {{ return [first, second]; }},
|
|
}});
|
|
controller.wire();
|
|
first.open = true;
|
|
first.fire('toggle');
|
|
second.open = true;
|
|
second.fire('toggle');
|
|
const escape = {{
|
|
key: 'Escape', prevented: 0, stopped: 0,
|
|
preventDefault() {{ this.prevented += 1; }},
|
|
stopPropagation() {{ this.stopped += 1; }},
|
|
}};
|
|
second.fire('keydown', escape);
|
|
process.stdout.write(JSON.stringify({{
|
|
firstOpen: first.open,
|
|
firstExpanded: first.attrs['aria-expanded'],
|
|
secondOpen: second.open,
|
|
secondExpanded: second.attrs['aria-expanded'],
|
|
secondFocus: second.summary.focusCount,
|
|
prevented: escape.prevented,
|
|
stopped: escape.stopped,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"firstOpen": False,
|
|
"firstExpanded": "false",
|
|
"secondOpen": False,
|
|
"secondExpanded": "false",
|
|
"secondFocus": 1,
|
|
"prevented": 1,
|
|
"stopped": 1,
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_my_work_cards_progressively_disclose_planning_on_phones():
|
|
html = await dashboard()
|
|
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
|
|
|
assert '<script src="static/card-planning.js"></script>' in html
|
|
assert "const cardPlanning = createCardPlanning(document);" in html
|
|
assert "cardPlanning.wire();" in html
|
|
assert (
|
|
'<details class="card-planning" data-card-planning>'
|
|
'<summary aria-expanded="false">Plan or defer</summary>'
|
|
'<div class="card-planning-actions">'
|
|
) in html
|
|
assert '.card-planning > summary { display:none;' in html
|
|
assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html
|
|
mobile = html.index('@media (max-width: 600px)')
|
|
assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:]
|
|
assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:]
|
|
assert "BASE + 'static/card-planning.js'" in service_worker
|
|
|
|
|
|
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
|
|
script = f"""
|
|
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
|
let claims = 0;
|
|
let finishClaim;
|
|
const states = [];
|
|
const controller = createUpdateOwnership({{
|
|
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
|
|
addToday: () => 'added',
|
|
onClaimed: item => states.push({{status:'reconciled', item}}),
|
|
onState: state => states.push(state),
|
|
}});
|
|
controller.open({{
|
|
repository:'stackchain/api', title:'Retry deploy',
|
|
issue:{{number:7, assignees:[], claimable:true}},
|
|
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
|
|
const first = controller.act();
|
|
const second = controller.act();
|
|
if (first !== second || claims !== 1) throw new Error('claim was not single-flight');
|
|
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
|
|
(async () => {{
|
|
await first;
|
|
const added = await controller.act();
|
|
process.stdout.write(JSON.stringify({{claims, added, states}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["claims"] == 1
|
|
assert output["added"] == "added"
|
|
assert output["states"][0] == {"action": "claim", "busy": False, "message": ""}
|
|
assert {"action": "claim", "busy": True, "message": "Assigning…"} in output["states"]
|
|
reconciled = next(state for state in output["states"] if state.get("status") == "reconciled")
|
|
assert reconciled["item"]["notification_id"] == 42
|
|
assert reconciled["item"]["repository"] == "stackchain/api"
|
|
assert reconciled["item"]["kind"] == "issue"
|
|
assert output["states"][-1] == {
|
|
"action": "today", "busy": False, "message": "Added to Today."
|
|
}
|
|
|
|
|
|
def test_update_ownership_conflict_removes_stale_action_without_reconciling():
|
|
script = f"""
|
|
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
|
const states = [];
|
|
let reconciled = 0;
|
|
const conflict = new Error('server wording'); conflict.status = 409;
|
|
const controller = createUpdateOwnership({{
|
|
claim: () => Promise.reject(conflict), addToday: () => 'added',
|
|
onClaimed: () => {{ reconciled += 1; }}, onState: state => states.push(state),
|
|
}});
|
|
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
|
(async () => {{
|
|
const result = await controller.act();
|
|
process.stdout.write(JSON.stringify({{result, reconciled, states}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["result"] == "conflict"
|
|
assert output["reconciled"] == 0
|
|
assert output["states"][-1] == {
|
|
"action": "hidden",
|
|
"busy": False,
|
|
"message": "Someone else claimed or closed this issue. The update is still unread.",
|
|
}
|
|
|
|
|
|
def test_unread_issue_update_claims_and_starts_once_without_marking_it_read():
|
|
script = f"""
|
|
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
|
let claims = 0;
|
|
let starts = 0;
|
|
let finishClaim;
|
|
const reconciled = [];
|
|
const states = [];
|
|
const controller = createUpdateOwnership({{
|
|
available: () => true,
|
|
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
|
|
addToday: () => 'added',
|
|
start: item => {{ starts += 1; return item.notification_id === 42 ? 'started' : 'broken'; }},
|
|
recover: () => {{ throw new Error('recovery should not run'); }},
|
|
onClaimed: item => reconciled.push(item),
|
|
onStartState: state => states.push(state),
|
|
onState: () => {{}},
|
|
}});
|
|
controller.open({{
|
|
repository:'stackchain/api', title:'Retry deploy',
|
|
issue:{{number:7, assignees:[], claimable:true}},
|
|
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
|
|
const first = controller.start();
|
|
const second = controller.start();
|
|
if (first !== second || claims !== 1) throw new Error('start was not single-flight');
|
|
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
|
|
(async () => {{
|
|
const result = await first;
|
|
process.stdout.write(JSON.stringify({{result, claims, starts, reconciled, states}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["result"] == "started"
|
|
assert output["claims"] == 1
|
|
assert output["starts"] == 1
|
|
assert len(output["reconciled"]) == 1
|
|
assert output["reconciled"][0]["notification_id"] == 42
|
|
assert output["reconciled"][0]["has_update"] is True
|
|
assert output["states"][-1] == {
|
|
"action": "hidden",
|
|
"busy": False,
|
|
"message": "Assigned, added to Today, and ready to work. The update is still unread.",
|
|
}
|
|
|
|
|
|
def test_unread_update_start_checks_today_capacity_before_claiming():
|
|
script = f"""
|
|
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
|
let claims = 0;
|
|
const states = [];
|
|
const controller = createUpdateOwnership({{
|
|
available: () => false,
|
|
claim: () => {{ claims += 1; return Promise.resolve({{number:7}}); }},
|
|
addToday: () => 'added', start: () => 'started',
|
|
onStartState: state => states.push(state), onState: () => {{}},
|
|
}});
|
|
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
|
(async () => {{
|
|
const result = await controller.start();
|
|
process.stdout.write(JSON.stringify({{result, claims, states}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["result"] == "full"
|
|
assert output["claims"] == 0
|
|
assert output["states"][-1] == {
|
|
"action": "start",
|
|
"busy": False,
|
|
"message": "Today is limited to 5 items. Remove one before taking ownership.",
|
|
}
|
|
|
|
|
|
def test_unread_update_opens_owned_issue_when_today_start_throws():
|
|
script = f"""
|
|
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
|
let recovered = null;
|
|
const states = [];
|
|
const controller = createUpdateOwnership({{
|
|
available: () => true,
|
|
claim: () => Promise.resolve({{number:7, title:'Retry deploy'}}),
|
|
addToday: () => 'added',
|
|
start: () => {{ throw new Error('storage failed'); }},
|
|
recover: item => {{ recovered = item; }},
|
|
onStartState: state => states.push(state), onState: () => {{}},
|
|
}});
|
|
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
|
(async () => {{
|
|
const result = await controller.start();
|
|
process.stdout.write(JSON.stringify({{result, recovered, states}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["result"] == "recovery"
|
|
assert output["recovered"]["number"] == 7
|
|
assert output["states"][-1] == {
|
|
"action": "hidden",
|
|
"busy": False,
|
|
"message": "Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.",
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/update-ownership.js"></script>' in html
|
|
assert 'id="update-ownership-action"' in html
|
|
assert 'id="update-ownership-start"' in html
|
|
assert '>Take ownership & start</button>' in html
|
|
assert 'hidden aria-describedby="update-sheet-status"' in html
|
|
assert 'const updateOwnership = createUpdateOwnership({' in html
|
|
assert 'available: () => createAndStart.available()' in html
|
|
assert 'const outcome = createAndStart.complete(item)' in html
|
|
assert "openRoutedWork(item, qs('#update-ownership-start'))" in html
|
|
assert 'updateOwnership.open(detail, selectedUpdate)' in html
|
|
assert "qs('#update-ownership-action').addEventListener('click'" in html
|
|
assert "qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start())" in html
|
|
assert "lastContextSnapshot.issues" in html
|
|
assert "todayWork.add(item)" in html
|
|
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
|
assert '.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
|
assert '.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden;' in html
|
|
|
|
|
|
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
const inputs = [
|
|
{{kind:'issue',repository:'stackchain/api',number:17}},
|
|
{{kind:'filed',repository:'stackchain/api',number:18}},
|
|
{{kind:'issue',repository:'stackchain/api',number:19,is_filed:true,is_assigned:false}},
|
|
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
|
|
{{kind:'review',repository:'stackchain/dashboard',number:42}},
|
|
{{kind:'update',notification_id:913}},
|
|
];
|
|
process.stdout.write(JSON.stringify({{
|
|
paths: inputs.map(routes.serialize),
|
|
parsed: inputs.map(item => routes.parse(routes.serialize(item))),
|
|
invalid: [
|
|
'#/my-work/issue/../../etc/1',
|
|
'#/my-work/issue/stackchain/api/not-a-number',
|
|
'#/my-work/update/-1',
|
|
'#/other/issue/stackchain/api/1',
|
|
].map(routes.parse),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"paths": [
|
|
"#/my-work/issue/stackchain/api/17",
|
|
"#/my-work/filed/stackchain/api/18",
|
|
"#/my-work/filed/stackchain/api/19",
|
|
"#/my-work/pull/stackchain/dashboard/42",
|
|
"#/my-work/review/stackchain/dashboard/42",
|
|
"#/my-work/update/913",
|
|
],
|
|
"parsed": [
|
|
{"kind": "issue", "repository": "stackchain/api", "number": 17},
|
|
{"kind": "filed", "repository": "stackchain/api", "number": 18},
|
|
{"kind": "filed", "repository": "stackchain/api", "number": 19},
|
|
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
|
|
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
|
|
{"kind": "update", "notification_id": 913},
|
|
],
|
|
"invalid": [None, None, None, None],
|
|
}
|
|
|
|
|
|
def test_work_route_controller_restores_direct_links_and_uses_history_for_close():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
const listeners = {{}};
|
|
const location = {{hash:'#/my-work/issue/stackchain/api/17', href:'https://forge.example/dashboard/#/my-work/issue/stackchain/api/17'}};
|
|
const calls = [];
|
|
const history = {{
|
|
state: null,
|
|
pushState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['push', hash]); }},
|
|
replaceState(state, _, hash) {{ this.state = state; location.hash = hash; calls.push(['replace', hash]); }},
|
|
back() {{ calls.push(['back']); location.hash = ''; listeners.popstate(); }},
|
|
}};
|
|
const controller = routes.createController({{
|
|
location, history,
|
|
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
|
onOpen(item) {{ calls.push(['open', item.kind, item.repository, item.number || item.notification_id]); }},
|
|
onClose() {{ calls.push(['close']); }},
|
|
onInvalid() {{ calls.push(['invalid']); }},
|
|
}});
|
|
controller.start();
|
|
controller.setItems([{{kind:'issue',repository:'stackchain/api',number:17}}]);
|
|
controller.close();
|
|
controller.open({{kind:'pull',repository:'stackchain/dashboard',number:9}});
|
|
controller.open({{kind:'review',repository:'stackchain/dashboard',number:10}}, {{replace:true}});
|
|
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [
|
|
["open", "issue", "stackchain/api", 17],
|
|
["back"],
|
|
["close"],
|
|
["push", "#/my-work/pull/stackchain/dashboard/9"],
|
|
["open", "pull", "stackchain/dashboard", 9],
|
|
["replace", "#/my-work/review/stackchain/dashboard/10"],
|
|
["open", "review", "stackchain/dashboard", 10],
|
|
]
|
|
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
|
|
|
|
|
|
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
const listeners = {{}};
|
|
const location = {{hash:'#/my-work/updates'}};
|
|
const calls = [];
|
|
const stack = ['#/my-work/updates'];
|
|
let cursor = 0;
|
|
const history = {{
|
|
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
|
|
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
|
|
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
|
|
}};
|
|
const controller = routes.createController({{
|
|
location, history,
|
|
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
|
onQueue: queue => calls.push(['queue', queue]),
|
|
onOpen: item => calls.push(['open', item.notification_id]),
|
|
onClose: () => calls.push(['close']),
|
|
onInvalid: () => calls.push(['invalid']),
|
|
}});
|
|
controller.start();
|
|
controller.setItems([]);
|
|
controller.open({{kind:'update', notification_id:42}});
|
|
controller.close();
|
|
process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.parse(location.hash)}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"calls": [
|
|
["queue", "update"],
|
|
["open", 42],
|
|
["close"],
|
|
["queue", "update"],
|
|
],
|
|
"hash": "#/my-work/updates",
|
|
"parsed": {"kind": "queue", "filter": "update"},
|
|
}
|
|
|
|
|
|
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
const canonical = ['today', 'agenda', 'attention', 'filed', 'updates', 'later', 'drafts'];
|
|
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
|
|
const listeners = {{}};
|
|
const location = {{hash:'#/my-work/agenda'}};
|
|
const calls = [];
|
|
const stack = ['#/my-work/agenda'];
|
|
let cursor = 0;
|
|
const history = {{
|
|
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
|
|
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
|
|
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
|
|
}};
|
|
const controller = routes.createController({{
|
|
location, history,
|
|
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
|
onQueue: queue => calls.push(['queue', queue]),
|
|
onOpen: item => calls.push(['open', item.number]),
|
|
onClose: () => calls.push(['close']),
|
|
onInvalid: () => calls.push(['invalid']),
|
|
}});
|
|
controller.start();
|
|
controller.setItems([{{kind:'issue', repository:'stackchain/dashboard', number:42}}]);
|
|
controller.open({{kind:'issue', repository:'stackchain/dashboard', number:42}});
|
|
controller.close();
|
|
controller.queue('draft');
|
|
process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"parsed": [
|
|
["today", {"kind": "queue", "filter": "today"}],
|
|
["agenda", {"kind": "queue", "filter": "agenda"}],
|
|
["attention", {"kind": "queue", "filter": "attention"}],
|
|
["filed", {"kind": "queue", "filter": "filed"}],
|
|
["updates", {"kind": "queue", "filter": "update"}],
|
|
["later", {"kind": "queue", "filter": "later"}],
|
|
["drafts", {"kind": "queue", "filter": "draft"}],
|
|
],
|
|
"calls": [
|
|
["queue", "agenda"],
|
|
["open", 42],
|
|
["close"],
|
|
["queue", "agenda"],
|
|
["queue", "draft"],
|
|
],
|
|
"hash": "#/my-work/drafts",
|
|
}
|
|
|
|
|
|
def test_mobile_queue_routes_reject_unknown_or_nested_fragments():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
process.stdout.write(JSON.stringify([
|
|
routes.parse('#/my-work/tomorrow'),
|
|
routes.parse('#/my-work/today/extra'),
|
|
routes.parse('#/my-work/drafts/1'),
|
|
]));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert json.loads(result.stdout) == [None, None, None]
|
|
|
|
|
|
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
const location = {{hash:'#/my-work/issue/stackchain/api/87'}};
|
|
const calls = [];
|
|
const controller = routes.createController({{
|
|
location,
|
|
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
|
|
eventTarget: {{addEventListener() {{}}}},
|
|
resolve: async route => {{
|
|
calls.push(['resolve', route.kind, route.repository, route.number]);
|
|
return {{kind:'issue', repository:'stackchain/api', number:87, title:'Older assigned issue'}};
|
|
}},
|
|
onResolving: route => calls.push(['resolving', route.number]),
|
|
onOpen: item => calls.push(['open', item.number, item.title]),
|
|
onClose() {{}},
|
|
onInvalid: () => calls.push(['invalid']),
|
|
onError: () => calls.push(['error']),
|
|
}});
|
|
(async () => {{
|
|
controller.start();
|
|
controller.setItems([]);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
|
}})();
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"calls": [
|
|
["resolving", 87],
|
|
["resolve", "issue", "stackchain/api", 87],
|
|
["open", 87, "Older assigned issue"],
|
|
],
|
|
"hash": "#/my-work/issue/stackchain/api/87",
|
|
}
|
|
|
|
|
|
def test_work_route_controller_dismisses_only_confirmed_unavailable_routes():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
const location = {{hash:'#/my-work/update/913'}};
|
|
const calls = [];
|
|
const unavailable = new Error('gone'); unavailable.unavailable = true;
|
|
const controller = routes.createController({{
|
|
location,
|
|
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
|
|
eventTarget: {{addEventListener() {{}}}},
|
|
resolve: async () => {{ throw unavailable; }},
|
|
onResolving() {{}}, onOpen() {{}}, onClose() {{}},
|
|
onInvalid: () => calls.push('invalid'),
|
|
onError: () => calls.push('error'),
|
|
}});
|
|
(async () => {{
|
|
controller.start(); controller.setItems([]);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
process.stdout.write(JSON.stringify(calls));
|
|
}})();
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == ["invalid"]
|
|
|
|
|
|
def test_work_route_share_prefers_native_share_and_falls_back_to_clipboard():
|
|
script = f"""
|
|
const routes = require({json.dumps(str(WORK_ROUTE))});
|
|
const calls = [];
|
|
(async () => {{
|
|
const native = await routes.share('https://forge.example/dashboard/#/my-work/update/9', {{
|
|
share: async payload => calls.push(['native', payload.url]),
|
|
}}, null);
|
|
const fallback = await routes.share('https://forge.example/dashboard/#/my-work/update/10', {{}}, {{
|
|
writeText: async text => calls.push(['clipboard', text]),
|
|
}});
|
|
process.stdout.write(JSON.stringify({{native, fallback, calls}}));
|
|
}})();
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"native": "shared",
|
|
"fallback": "copied",
|
|
"calls": [
|
|
["native", "https://forge.example/dashboard/#/my-work/update/9"],
|
|
["clipboard", "https://forge.example/dashboard/#/my-work/update/10"],
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
|
|
html = await dashboard()
|
|
|
|
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
|
|
assert "const readOnly = issueController.readOnly(item);" in html
|
|
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
|
|
assert "#issue-sheet.read-only .issue-comment-composer" not in html
|
|
assert "#issue-sheet.read-only .issue-attachment-controls" in html
|
|
assert "#issue-sheet.read-only #issue-planning" in html
|
|
assert "#issue-sheet.read-only #issue-handoff" in html
|
|
assert "#issue-sheet.read-only #release-issue" in html
|
|
assert "#issue-sheet.read-only #close-issue" in html
|
|
assert "#issue-sheet.read-only .detail-defer" in html
|
|
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
|
|
assert "if (readOnly) qs('#load-older-issue-comments').hidden = true;" not in html
|
|
assert "readOnly ? 'Filed issue ready · follow-up enabled'" in html
|
|
assert "paintIssueConversation(await issueConversation.loadOlder(), null)" in html
|
|
assert ".issue-comment-composer textarea" in html
|
|
assert ".issue-comment-composer button" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/work-route.js"></script>' in html
|
|
assert 'const workRoute = createWorkRoute.createController({' in html
|
|
assert 'workRoute.setItems(lastMyWork);' in html
|
|
assert "api('api/v1/work-route?' + params.toString())" in html
|
|
assert "Loading shared work item…" in html
|
|
assert 'id="retry-work-route"' in html
|
|
assert 'href="' + "' + escAttr(createWorkRoute.serialize(" in html
|
|
assert '.read-update { min-height:44px; width:100%; display:flex;' in html
|
|
assert 'workRoute.close();' in html
|
|
assert html.count('class="share-work-route"') == 4
|
|
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
|
|
assert 'Route unavailable · this item is no longer in My Work.' in html
|
|
assert "onQueue: openWorkQueueRoute" in html
|
|
assert "selectWorkQueue(filter, { preserveRoute:true })" in html
|
|
assert "workRoute.queue(filter)" in html
|
|
assert "openDeliveryReceiptRoute" not in html
|
|
|
|
|
|
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_surfaces_and_ranks_due_issues_after_p0_before_ordinary_work():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [
|
|
{"number": 1, "title": "Ordinary", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-07T12:00:00Z"},
|
|
{"number": 2, "title": "Due today", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
|
|
{"number": 3, "title": "Overdue", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
|
|
{"number": 4, "title": "P0 future", "repository": "stackchain/api",
|
|
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
|
|
],
|
|
"pull_requests": [],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const queue = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
|
|
process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason:item.reason,due_label:item.due_label}}))));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == [
|
|
{"title": "P0 future", "reason": "P0 priority", "due_label": "Due Aug 10"},
|
|
{"title": "Overdue", "reason": "Overdue", "due_label": "Overdue"},
|
|
{"title": "Due today", "reason": "Due today", "due_label": "Due today"},
|
|
{"title": "Ordinary", "reason": "Assigned to you"},
|
|
]
|
|
|
|
|
|
def test_attention_includes_only_assigned_deadline_and_priority_critical_work():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [
|
|
{"number": 1, "title": "Assigned P0", "repository": "stackchain/api",
|
|
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
|
|
{"number": 2, "title": "Assigned overdue", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
|
|
{"number": 3, "title": "Assigned due today", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
|
|
{"number": 4, "title": "Assigned future", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
|
|
{"number": 5, "title": "Unassigned P0", "repository": "stackchain/api",
|
|
"labels": ["critical"], "assignees": [], "due_date": "2026-08-10T23:59:59Z"},
|
|
{"number": 6, "title": "Unassigned overdue", "repository": "stackchain/api",
|
|
"labels": [], "assignees": [], "due_date": "2026-08-06T23:59:59Z"},
|
|
],
|
|
"pull_requests": [
|
|
{"number": 7, "title": "Updated review", "repository": "stackchain/web",
|
|
"labels": [], "assignees": [], "work_reasons": ["review_requested"]},
|
|
],
|
|
"notifications": [
|
|
{"id": 70, "number": 7, "repository": "stackchain/web", "unread": True,
|
|
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/7"},
|
|
],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
|
|
const attention = buildMyWork.filterMyWork(items, 'attention');
|
|
process.stdout.write(JSON.stringify({{
|
|
titles: attention.map(item => item.title),
|
|
count: buildMyWork.countMyWork(items).attention,
|
|
reasons: Object.fromEntries(attention.map(item => [item.title, item.attention_reason])),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"titles": ["Assigned P0", "Updated review", "Assigned overdue", "Assigned due today"],
|
|
"count": 4,
|
|
"reasons": {
|
|
"Assigned P0": "P0 priority",
|
|
"Updated review": "Unread update",
|
|
"Assigned overdue": "Overdue",
|
|
"Assigned due today": "Due today",
|
|
},
|
|
}
|
|
|
|
|
|
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [
|
|
{"number": 1, "title": "Older", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
|
|
{"number": 2, "title": "Newer", "repository": "stackchain/api",
|
|
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T12:00:00Z"},
|
|
],
|
|
"pull_requests": [],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const original = {json.dumps(payload)};
|
|
const updated = buildMyWork.replaceIssueLabels(original, 'stackchain/api', 1, ['P0']);
|
|
process.stdout.write(JSON.stringify({{
|
|
titles: buildMyWork(updated).map(item => item.title),
|
|
labels: updated.issues[0].labels,
|
|
original: original.issues[0].labels,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"titles": ["Older", "Newer"],
|
|
"labels": ["P0"],
|
|
"original": [],
|
|
}
|
|
|
|
|
|
def test_confirmed_issue_content_updates_my_work_without_mutating_snapshot():
|
|
payload = {
|
|
"issues": [{"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body"}],
|
|
"pull_requests": [],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const original = {json.dumps(payload)};
|
|
const updated = buildMyWork.replaceIssueContent(
|
|
original, 'stackchain/api', 17,
|
|
{{title:'Clarified',body:'New body',updated_at:'2026-08-07T10:01:00Z'}}
|
|
);
|
|
process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:original.issues[0]}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert json.loads(result.stdout) == {
|
|
"updated": {
|
|
"number": 17, "repository": "stackchain/api", "title": "Clarified",
|
|
"body": "New body", "updated_at": "2026-08-07T10:01:00Z",
|
|
},
|
|
"original": {
|
|
"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body",
|
|
},
|
|
}
|
|
|
|
|
|
def test_issue_sheet_identifies_delegated_filings_as_read_only():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}});
|
|
process.stdout.write(JSON.stringify([
|
|
controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}),
|
|
controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}),
|
|
controller.readOnly({{kind:'issue',is_assigned:true}}),
|
|
]));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert json.loads(result.stdout) == [True, False, False]
|
|
|
|
|
|
def test_issue_sheet_loads_filed_items_through_read_only_access():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const urls = [];
|
|
const controller = createIssueSheet({{
|
|
fetchJson: async url => {{ urls.push(url); return {{}}; }}, storage:null,
|
|
}});
|
|
Promise.all([
|
|
controller.load({{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}}),
|
|
controller.load({{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}),
|
|
]).then(() => process.stdout.write(JSON.stringify(urls)));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert json.loads(result.stdout) == [
|
|
"api/v1/repos/stackchain/api/issues/17/detail",
|
|
"api/v1/repos/stackchain/api/issues/18/detail?access=filed",
|
|
]
|
|
|
|
|
|
def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const createConversationPager = require({json.dumps(str(ISSUE_SHEET.parent / "conversation.js"))});
|
|
const calls = [];
|
|
const controller = createIssueSheet({{
|
|
fetchJson: async (url, options={{}}) => {{
|
|
calls.push({{url, method:options.method || 'GET', key:options.headers?.['Idempotency-Key'] || ''}});
|
|
if (options.method === 'POST') return {{id:22, body:'Clarifying detail'}};
|
|
return {{comments:[], page:1, older_page:null, total:0}};
|
|
}},
|
|
storage:null,
|
|
createOperationId:() => 'filed-followup-876',
|
|
createConversationPager,
|
|
}});
|
|
const filed = {{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}};
|
|
const assigned = {{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}};
|
|
const initial = {{comments:[], page:2, older_page:1, total:0}};
|
|
Promise.all([
|
|
controller.conversation(filed, initial).loadOlder(),
|
|
controller.comment(filed, 'Clarifying detail'),
|
|
controller.conversation(assigned, initial).loadOlder(),
|
|
]).then(() => process.stdout.write(JSON.stringify(calls)));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert json.loads(result.stdout) == [
|
|
{
|
|
"url": "api/v1/repos/stackchain/api/issues/18/comments?page=1&limit=20&access=filed",
|
|
"method": "GET",
|
|
"key": "",
|
|
},
|
|
{
|
|
"url": "api/v1/repos/stackchain/api/issues/18/comments?access=filed",
|
|
"method": "POST",
|
|
"key": "filed-followup-876",
|
|
},
|
|
{
|
|
"url": "api/v1/repos/stackchain/api/issues/17/comments?page=1&limit=20",
|
|
"method": "GET",
|
|
"key": "",
|
|
},
|
|
]
|
|
|
|
|
|
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
let calls = 0;
|
|
let finish;
|
|
const controller = createIssueSheet({{
|
|
fetchJson: () => {{
|
|
calls += 1;
|
|
return new Promise(resolve => {{ finish = resolve; }});
|
|
}},
|
|
storage: null,
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:17}};
|
|
const first = controller.release(item, 'timmy');
|
|
const duplicate = controller.release(item, 'timmy');
|
|
finish({{number:17, repository:'stackchain/api', assignees:[], available:true}});
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, same: first === duplicate, 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["same"] is True
|
|
assert output["results"][0]["available"] is True
|
|
|
|
|
|
def test_related_issue_draft_reuses_plan_but_resets_issue_specific_work():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const related = createIssueSheet.buildRelatedDraft({{
|
|
repository:'stackchain/dashboard', title:'Completed discovery', body:'Filled private report',
|
|
labelIds:[7, 7, 9], milestoneId:4, dueDate:'2026-08-20',
|
|
assignee:'alex', assigneeName:'Alexander', unassigned:false,
|
|
templateId:'bug.yml', templateName:'Bug report', capturedBody:'Original notes',
|
|
blockers:[{{repository:'stackchain/api',number:2}}], estimateMinutes:45,
|
|
completionIntent:'create-and-start', operationId:'already-delivered',
|
|
attachment:{{name:'secret.png'}}, duplicateAcknowledged:true,
|
|
}}, {{id:'bug.yml', name:'Bug report', body:'## What happened?\\n\\n## Expected'}});
|
|
process.stdout.write(JSON.stringify(related));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output == {
|
|
"repository": "stackchain/dashboard",
|
|
"title": "",
|
|
"body": "## What happened?\n\n## Expected",
|
|
"labelIds": [7, 9],
|
|
"milestoneId": 4,
|
|
"dueDate": "2026-08-20",
|
|
"assignee": "alex",
|
|
"assigneeName": "Alexander",
|
|
"templateId": "bug.yml",
|
|
"templateName": "Bug report",
|
|
"capturedBody": "",
|
|
}
|
|
|
|
|
|
def test_issue_release_rejects_response_that_still_assigns_current_user():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const controller = createIssueSheet({{
|
|
fetchJson: async () => ({{number:17, assignees:['timmy']}}), storage: null,
|
|
}});
|
|
controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
|
|
.then(() => process.stdout.write('unexpected'))
|
|
.catch(error => process.stdout.write(error.message));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert result.stdout == "Issue release was not confirmed."
|
|
|
|
|
|
def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
let calls = [];
|
|
let finish;
|
|
const controller = createIssueSheet({{
|
|
fetchJson: (url, options) => {{
|
|
calls.push({{url, options}});
|
|
return new Promise(resolve => {{ finish = resolve; }});
|
|
}},
|
|
storage: null,
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:17}};
|
|
const first = controller.handoff(item, 'alex', 'timmy');
|
|
const duplicate = controller.handoff(item, 'alex', 'timmy');
|
|
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, same: first === duplicate, results
|
|
}})));
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["same"] is True
|
|
assert len(output["calls"]) == 1
|
|
assert output["calls"][0]["url"].endswith("/issues/17/handoff")
|
|
assert output["calls"][0]["options"]["method"] == "PATCH"
|
|
assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
|
|
assert output["results"][0]["assignees"] == ["alex"]
|
|
|
|
|
|
def test_issue_handoff_candidates_use_the_assigned_issue_route():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
let call;
|
|
const controller = createIssueSheet({{
|
|
fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
|
|
storage: null,
|
|
}});
|
|
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
|
.then(result => process.stdout.write(JSON.stringify({{call, result}})));
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
|
|
assert output["call"]["options"]["headers"]["Accept"] == "application/json"
|
|
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
|
|
|
|
|
def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
let calls = [];
|
|
let finish;
|
|
const controller = createIssueSheet({{
|
|
storage:null,
|
|
fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }},
|
|
}});
|
|
const item = {{repository:'stackchain/dashboard', number:17}};
|
|
const blocker = {{repository:'stackchain/api', number:9}};
|
|
const first = controller.updateBlocker(item, blocker, false);
|
|
const duplicate = controller.updateBlocker(item, blocker, false);
|
|
finish({{repository:item.repository, number:item.number, dependencies_available:true,
|
|
dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}});
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
|
same:first === duplicate, result:results[0]
|
|
}})));
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["same"] is True
|
|
assert output["calls"] == [{
|
|
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
|
"method": "POST",
|
|
"body": {"repository": "stackchain/api", "number": 9},
|
|
}]
|
|
assert output["result"]["dependencies"][0]["number"] == 9
|
|
|
|
|
|
def test_issue_blocker_removal_rejects_unconfirmed_canonical_state():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const controller = createIssueSheet({{
|
|
storage:null,
|
|
fetchJson:async () => ({{dependencies_available:true,
|
|
dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}),
|
|
}});
|
|
controller.updateBlocker(
|
|
{{repository:'stackchain/dashboard',number:17}},
|
|
{{repository:'stackchain/api',number:9}}, true
|
|
).then(() => process.stdout.write('unexpected'))
|
|
.catch(error => process.stdout.write(error.message));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert result.stdout == "Blocker change was not confirmed."
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls():
|
|
html = await dashboard()
|
|
|
|
assert 'id="manage-issue-blockers"' in html
|
|
assert 'id="issue-blocker-search" type="search"' in html
|
|
assert 'id="issue-blocker-results" role="listbox"' in html
|
|
assert 'id="cancel-issue-blocker"' in html
|
|
assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html
|
|
assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html
|
|
assert "api('api/v1/search?q='" in html
|
|
assert "result.kind === 'issue' && result.state === 'open'" in html
|
|
assert "renderPlanIssueDependencies(selectedIssueDetail)" in html
|
|
assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html
|
|
assert "width:100%; max-width:100%; box-sizing:border-box" in html
|
|
assert "overflow-wrap:anywhere" in html
|
|
|
|
|
|
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let calls = [];
|
|
let finish;
|
|
const controller = createIssueSheet({{
|
|
storage,
|
|
fetchJson:(url, options) => {{
|
|
calls.push({{url, options}});
|
|
return new Promise(resolve => {{ finish = resolve; }});
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:17}};
|
|
const draft = {{title:'Clarified scope', body:'Updated body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}};
|
|
controller.saveEditDraft(item, draft);
|
|
const first = controller.updateContent(item, draft);
|
|
const duplicate = controller.updateContent(item, draft);
|
|
const during = controller.loadEditDraft(item);
|
|
finish({{repository:'stackchain/api',number:17,title:'Clarified scope',body:'Updated body',updated_at:'2026-08-07T10:01:00Z'}});
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
|
same:first === duplicate, during, after:controller.loadEditDraft(item), results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["calls"] == [{
|
|
"url": "api/v1/repos/stackchain/api/issues/17/content",
|
|
"method": "PATCH",
|
|
"body": {
|
|
"title": "Clarified scope", "body": "Updated body",
|
|
"expected_updated_at": "2026-08-07T10:00:00Z",
|
|
},
|
|
}]
|
|
assert output["same"] is True
|
|
assert output["during"] == {
|
|
"title": "Clarified scope", "body": "Updated body",
|
|
"expectedUpdatedAt": "2026-08-07T10:00:00Z",
|
|
}
|
|
assert output["after"] is None
|
|
assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"
|
|
|
|
|
|
def test_issue_due_date_update_is_single_flight_and_keeps_draft_until_confirmed():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let calls = [];
|
|
let finish;
|
|
const controller = createIssueSheet({{
|
|
storage,
|
|
fetchJson:(url, options) => {{
|
|
calls.push({{url, options}});
|
|
return new Promise(resolve => {{ finish = resolve; }});
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:17}};
|
|
const first = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
|
|
const duplicate = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
|
|
const during = controller.loadDueDateDraft(item);
|
|
finish({{repository:'stackchain/api',number:17,state:'open',due_date:'2026-08-09T23:59:59Z'}});
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
|
same:first === duplicate, during, after:controller.loadDueDateDraft(item), results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["calls"] == [{
|
|
"url": "api/v1/repos/stackchain/api/issues/17/due-date",
|
|
"method": "PATCH",
|
|
"body": {"due_date": "2026-08-09T23:59:59Z"},
|
|
}]
|
|
assert output["same"] is True
|
|
assert output["during"] == "2026-08-09T23:59:59Z"
|
|
assert output["after"] is None
|
|
assert output["results"][0]["due_date"] == "2026-08-09T23:59:59Z"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_badge():
|
|
html = await dashboard()
|
|
|
|
assert 'id="issue-due-date" type="date"' in html
|
|
assert 'id="save-issue-due-date"' in html
|
|
assert 'id="clear-issue-due-date"' in html
|
|
assert 'class="pill due-badge"' in html
|
|
assert '.issue-due-editor input, .issue-due-editor button { min-height:44px;' in html
|
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
|
|
html = await dashboard()
|
|
|
|
assert 'id="issue-handoff-recipient"' in html
|
|
assert 'id="load-issue-handoff"' in html
|
|
assert 'id="confirm-issue-handoff"' in html
|
|
assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
|
|
assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
|
|
assert "issueController.loadHandoffCandidates(selectedIssue)" in html
|
|
assert "issueController.handoff(selectedIssue, recipient" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
|
html = await dashboard()
|
|
|
|
assert 'id="create-issue-milestone"' in html
|
|
assert 'id="create-issue-due-date" type="date"' in html
|
|
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
|
|
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
|
|
assert 'issueFilingMetadata.load(repository, selected)' in html
|
|
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
|
|
html = await dashboard()
|
|
|
|
assert '<link rel="manifest" href="manifest.webmanifest"' in html
|
|
assert "issueCapture.stageSharedContent(sharedLaunch)" in html
|
|
assert 'id="use-shared-content"' in html
|
|
assert 'id="resume-issue-draft"' in html
|
|
assert 'id="shared-content-conflict"' in html
|
|
assert '.shared-content-actions button { min-height:44px;' in html
|
|
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
|
assert "navigator.serviceWorker.register('service-worker.js')" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
|
html = await dashboard()
|
|
|
|
assert 'id="work-milestone-filter"' in html
|
|
assert '<option value="unplanned">Unplanned</option>' in html
|
|
assert 'id="issue-milestone"' in html
|
|
assert 'id="save-issue-milestone"' in html
|
|
assert 'class="pill milestone-badge"' in html
|
|
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
|
|
assert "getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone" in html
|
|
assert 'buildMyWork.replaceIssueMilestone(' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
|
|
html = await dashboard()
|
|
|
|
body = html.index('id="issue-sheet-body"')
|
|
conversation = html.index('<h2>Full conversation</h2>', body)
|
|
planning = html.index('id="issue-planning"', conversation)
|
|
labels = html.index('id="issue-label-editor"', planning)
|
|
milestone = html.index('id="issue-milestone"', planning)
|
|
|
|
assert '<summary>Plan & edit</summary>' in html[planning:labels]
|
|
assert '<details class="issue-planning" id="issue-planning">' in html
|
|
assert body < conversation < planning < labels < milestone
|
|
|
|
|
|
def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const calls = [];
|
|
let fail = true;
|
|
const planning = createIssueSheet.createPlanningLoader({{
|
|
loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
|
|
loadMilestones: async item => {{
|
|
calls.push('milestones:' + item.number);
|
|
if (fail) throw new Error('offline');
|
|
return [{{id:9,title:'RC'}}];
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:17}};
|
|
(async () => {{
|
|
const before = calls.slice();
|
|
let failed = false;
|
|
try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
|
|
fail = false;
|
|
const first = await planning.open(item);
|
|
const second = await planning.open(item);
|
|
process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
|
|
}})();
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"before": [],
|
|
"failed": True,
|
|
"calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
|
|
"same": True,
|
|
"first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
|
|
html = await dashboard()
|
|
open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]
|
|
|
|
assert 'loadIssueLabelEditor(' not in open_handler
|
|
assert 'loadIssueMilestoneEditor(' not in open_handler
|
|
assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler
|
|
assert "qs('#issue-planning').addEventListener('toggle'" in html
|
|
assert 'planningLoader.open(selectedIssue)' in html
|
|
assert 'id="retry-issue-planning"' in html
|
|
|
|
|
|
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_attention_filter_unions_updates_and_reviews_without_double_counting():
|
|
items = [
|
|
{"title": "Unread issue", "kind": "issue", "has_update": True, "is_review": False},
|
|
{"title": "Requested review", "kind": "pull", "has_update": False, "is_review": True},
|
|
{"title": "Updated review", "kind": "pull", "has_update": True, "is_review": True},
|
|
{"title": "Ordinary assignment", "kind": "issue", "has_update": False, "is_review": False},
|
|
]
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = {json.dumps(items)};
|
|
process.stdout.write(JSON.stringify({{
|
|
attention: buildMyWork.filterMyWork(items, 'attention').map(item => item.title),
|
|
count: buildMyWork.countMyWork(items).attention,
|
|
}}));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"attention": ["Unread issue", "Requested review", "Updated review"],
|
|
"count": 3,
|
|
}
|
|
|
|
|
|
def test_later_queue_defers_work_locally_and_scopes_it_to_confirmed_login():
|
|
script = f"""
|
|
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let login = 'timmy';
|
|
const item = {{kind:'pull',is_review:true,repository:'stackchain/api',number:17,title:'Review me'}};
|
|
const store = createLaterWork({{storage,getLogin:() => login,now:() => new Date('2026-08-08T12:00:00Z')}});
|
|
const deferred = store.defer(item, new Date('2026-08-08T16:00:00Z'));
|
|
const timmy = store.partition([item]);
|
|
login = 'alexander';
|
|
const alexander = store.partition([item]);
|
|
process.stdout.write(JSON.stringify({{
|
|
deferred,
|
|
timmy:{{active:timmy.active.length,later:timmy.later}},
|
|
alexander:{{active:alexander.active.length,later:alexander.later.length}},
|
|
keys:Array.from(values.keys()),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"deferred": "deferred",
|
|
"timmy": {
|
|
"active": 0,
|
|
"later": [{
|
|
"kind": "pull", "is_review": True, "repository": "stackchain/api",
|
|
"number": 17, "title": "Review me", "deferred_until": "2026-08-08T16:00:00.000Z",
|
|
}],
|
|
},
|
|
"alexander": {"active": 1, "later": 0},
|
|
"keys": ["stackchain.later-work.v1.timmy"],
|
|
}
|
|
|
|
|
|
def test_later_queue_reports_invalid_identity_and_failed_storage_without_false_success():
|
|
script = f"""
|
|
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
|
let login = '';
|
|
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
|
|
const unavailable = createLaterWork({{
|
|
storage: {{getItem:() => null,setItem:() => {{ throw new Error('quota'); }},removeItem:() => {{}}}},
|
|
getLogin:() => login, now:() => new Date('2026-08-08T12:00:00Z'),
|
|
}});
|
|
const noIdentity = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
|
|
login = 'timmy';
|
|
const invalid = unavailable.defer(item, new Date('2026-08-08T11:00:00Z'));
|
|
const failedWrite = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
|
|
process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"noIdentity": "unavailable",
|
|
"invalid": "invalid",
|
|
"failedWrite": "unavailable",
|
|
}
|
|
|
|
|
|
def test_later_queue_defers_a_batch_atomically_with_one_wake_time_and_change_per_item():
|
|
script = f"""
|
|
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
|
const values = new Map();
|
|
const changes = [];
|
|
let writes = 0;
|
|
let fail = false;
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => {{ writes += 1; if (fail) throw new Error('quota'); values.set(key,value); }},
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const first = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
|
|
const second = {{kind:'update',repository:'stackchain/web',number:8,notification_id:92}};
|
|
const store = createLaterWork({{
|
|
storage, getLogin:() => 'timmy', now:() => new Date('2026-08-08T12:00:00Z'),
|
|
setTimer:() => 1, clearTimer:() => {{}},
|
|
onChange:(...change) => changes.push(change),
|
|
}});
|
|
const deferred = store.deferMany([first, second], new Date('2026-08-09T09:00:00Z'));
|
|
const saved = store.partition([first, second]);
|
|
const beforeFailure = JSON.stringify(store.read());
|
|
fail = true;
|
|
const unavailable = store.deferMany([
|
|
{{kind:'update',repository:'stackchain/api',number:19,notification_id:93}},
|
|
{{kind:'update',repository:'stackchain/api',number:20,notification_id:94}},
|
|
], new Date('2026-08-10T09:00:00Z'));
|
|
process.stdout.write(JSON.stringify({{
|
|
deferred, unavailable, writes, changes,
|
|
later:saved.later.map(item => [item.notification_id,item.deferred_until]),
|
|
unchanged:beforeFailure === JSON.stringify(store.read()),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"deferred": "deferred",
|
|
"unavailable": "unavailable",
|
|
"writes": 2,
|
|
"changes": [
|
|
["defer", "update:stackchain/api:17:91", "2026-08-09T09:00:00.000Z"],
|
|
["defer", "update:stackchain/web:8:92", "2026-08-09T09:00:00.000Z"],
|
|
],
|
|
"later": [
|
|
[91, "2026-08-09T09:00:00.000Z"],
|
|
[92, "2026-08-09T09:00:00.000Z"],
|
|
],
|
|
"unchanged": True,
|
|
}
|
|
|
|
|
|
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
|
|
script = f"""
|
|
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let clock = new Date('2026-08-08T12:00:00Z');
|
|
let scheduled = null;
|
|
let wakes = 0;
|
|
const store = createLaterWork({{
|
|
storage, getLogin:() => 'timmy', now:() => clock,
|
|
setTimer:(callback, delay) => {{ scheduled = {{callback,delay}}; return 7; }},
|
|
clearTimer:() => {{}}, onWake:() => {{ wakes += 1; }},
|
|
}});
|
|
const kept = {{kind:'issue',repository:'stackchain/api',number:17,title:'Kept'}};
|
|
const gone = {{kind:'issue',repository:'stackchain/api',number:18,title:'Gone'}};
|
|
store.defer(kept, new Date('2026-08-08T13:00:00Z'));
|
|
store.defer(gone, new Date('2026-08-09T13:00:00Z'));
|
|
const before = store.partition([kept]);
|
|
const persisted = JSON.parse(values.get('stackchain.later-work.v1.timmy'));
|
|
clock = new Date('2026-08-08T13:00:01Z');
|
|
scheduled.callback();
|
|
const after = store.partition([kept]);
|
|
process.stdout.write(JSON.stringify({{
|
|
before:{{active:before.active.length,later:before.later.length}},
|
|
persisted:Object.keys(persisted), delay:scheduled.delay, wakes,
|
|
after:{{active:after.active.map(item => item.title),later:after.later.length}},
|
|
storageEmpty:!values.has('stackchain.later-work.v1.timmy'),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"before": {"active": 0, "later": 1},
|
|
"persisted": ["issue:stackchain/api:17:"],
|
|
"delay": 3600000,
|
|
"wakes": 1,
|
|
"after": {"active": ["Kept"], "later": 0},
|
|
"storageEmpty": True,
|
|
}
|
|
|
|
|
|
def test_later_queue_preserves_rank_and_retains_unloaded_paginated_work():
|
|
script = f"""
|
|
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const store = createLaterWork({{
|
|
storage,getLogin:() => 'timmy',now:() => new Date('2026-08-08T12:00:00Z'),
|
|
setTimer:() => 1, clearTimer:() => {{}},
|
|
}});
|
|
const first = {{kind:'issue',repository:'stackchain/api',number:1,title:'Higher priority'}};
|
|
const second = {{kind:'pull',repository:'stackchain/api',number:2,title:'Lower priority'}};
|
|
store.defer(second, new Date('2026-08-09T12:00:00Z'));
|
|
store.defer(first, new Date('2026-08-09T12:00:00Z'));
|
|
store.partition([first], {{pruneMissing:false}});
|
|
const visible = store.partition([first, second], {{pruneMissing:false}});
|
|
process.stdout.write(JSON.stringify({{
|
|
titles:visible.later.map(item => item.title),
|
|
records:Object.keys(JSON.parse(values.get('stackchain.later-work.v1.timmy'))).length,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"titles": ["Higher priority", "Lower priority"],
|
|
"records": 2,
|
|
}
|
|
|
|
|
|
def test_later_queue_presets_and_bring_back_now_preserve_work_identity():
|
|
script = f"""
|
|
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const now = new Date('2026-08-08T12:00:00Z');
|
|
const store = createLaterWork({{storage,getLogin:() => 'timmy',now:() => now}});
|
|
const item = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
|
|
const today = store.presetUntil('today');
|
|
const tomorrow = store.presetUntil('tomorrow');
|
|
store.defer(item, tomorrow);
|
|
const removed = store.restore(item);
|
|
process.stdout.write(JSON.stringify({{
|
|
today:today.toISOString(), tomorrow:tomorrow.toISOString(), removed,
|
|
partition:store.partition([item]),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True,
|
|
env={**os.environ, "TZ": "UTC"},
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"today": "2026-08-08T16:00:00.000Z",
|
|
"tomorrow": "2026-08-09T09:00:00.000Z",
|
|
"removed": True,
|
|
"partition": {"active": [{
|
|
"kind": "update", "repository": "stackchain/api", "number": 17,
|
|
"notification_id": 91,
|
|
}], "later": []},
|
|
}
|
|
|
|
|
|
def test_starting_deferred_work_moves_it_to_today_before_opening_exact_item():
|
|
script = f"""
|
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
|
const calls = [];
|
|
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Resume me'}};
|
|
const controller = createLaterAndStart({{
|
|
todayWork: {{
|
|
identity:saved => saved.repository + '#' + saved.number,
|
|
add:saved => {{ calls.push(['today-add', saved.number]); return 'added'; }},
|
|
remove:saved => {{ calls.push(['today-remove', saved.number]); return true; }},
|
|
}},
|
|
todaySync: {{
|
|
enqueue:(action, identity) => {{ calls.push(['today-sync', action, identity]); return true; }},
|
|
flush:() => calls.push(['today-flush']),
|
|
}},
|
|
laterWork: {{restore:saved => {{ calls.push(['later-restore', saved.number]); return true; }}}},
|
|
refresh:() => calls.push(['refresh']),
|
|
warm:() => calls.push(['warm']),
|
|
start:saved => {{ calls.push(['start', saved.number]); return Promise.resolve('opened'); }},
|
|
announce:message => calls.push(['announce', message]),
|
|
}});
|
|
(async () => {{
|
|
const result = await controller.start(item);
|
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], capture_output=True, text=True
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"result": "started",
|
|
"calls": [
|
|
["today-add", 17],
|
|
["today-sync", "add", "stackchain/api#17"],
|
|
["later-restore", 17],
|
|
["refresh"],
|
|
["today-flush"],
|
|
["warm"],
|
|
["start", 17],
|
|
["announce", "Moved to Today and opened."],
|
|
],
|
|
}
|
|
|
|
|
|
def test_starting_deferred_work_keeps_later_when_today_is_full():
|
|
script = f"""
|
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
|
const calls = [];
|
|
const controller = createLaterAndStart({{
|
|
todayWork: {{identity:() => 'issue:x/y:7', add:() => 'full', remove:() => calls.push('remove')}},
|
|
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
|
|
laterWork: {{restore:() => calls.push('restore')}},
|
|
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
|
start:() => calls.push('start'), announce:message => calls.push(message),
|
|
}});
|
|
(async () => {{
|
|
const result = await controller.start({{kind:'issue',repository:'x/y',number:7}});
|
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"result": "full",
|
|
"calls": ["Today is limited to 5 items. Remove one, then try Start now again."],
|
|
}
|
|
|
|
|
|
def test_starting_deferred_work_rolls_back_today_when_sync_admission_fails():
|
|
script = f"""
|
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
|
const calls = [];
|
|
const item = {{kind:'pull',repository:'x/y',number:8}};
|
|
const controller = createLaterAndStart({{
|
|
todayWork: {{
|
|
identity:() => 'pull:x/y:8', add:() => {{ calls.push('add'); return 'added'; }},
|
|
remove:() => {{ calls.push('remove'); return true; }},
|
|
}},
|
|
todaySync: {{enqueue:() => {{ calls.push('enqueue'); return false; }}, flush:() => calls.push('flush')}},
|
|
laterWork: {{restore:() => calls.push('restore')}},
|
|
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
|
start:() => calls.push('start'), announce:message => calls.push(message),
|
|
}});
|
|
(async () => {{
|
|
const result = await controller.start(item);
|
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"result": "sync-unavailable",
|
|
"calls": [
|
|
"add", "enqueue", "remove",
|
|
"Today sync is unavailable. The item remains in Later; try again.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_starting_deferred_work_rolls_back_today_when_later_cannot_be_removed():
|
|
script = f"""
|
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
|
const calls = [];
|
|
const item = {{kind:'review',repository:'x/y',number:9}};
|
|
const controller = createLaterAndStart({{
|
|
todayWork: {{
|
|
identity:() => 'review:x/y:9', add:() => 'added',
|
|
remove:() => {{ calls.push('remove'); return true; }},
|
|
}},
|
|
todaySync: {{
|
|
enqueue:(action, id) => {{ calls.push(['enqueue', action, id]); return true; }},
|
|
flush:() => calls.push('flush'),
|
|
}},
|
|
laterWork: {{restore:() => false}}, refresh:() => calls.push('refresh'),
|
|
warm:() => calls.push('warm'), start:() => calls.push('start'),
|
|
announce:message => calls.push(message),
|
|
}});
|
|
(async () => {{
|
|
const result = await controller.start(item);
|
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"result": "later-unavailable",
|
|
"calls": [
|
|
["enqueue", "add", "review:x/y:9"], "remove",
|
|
["enqueue", "remove", "review:x/y:9"], "flush",
|
|
"Could not remove this item from Later. Nothing was started; try again.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_starting_deferred_work_reuses_existing_today_item_without_duplicate_sync():
|
|
script = f"""
|
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
|
const calls = [];
|
|
const item = {{kind:'issue',repository:'x/y',number:10}};
|
|
const controller = createLaterAndStart({{
|
|
todayWork: {{identity:() => 'issue:x/y:10', add:() => 'exists', remove:() => calls.push('remove')}},
|
|
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
|
|
laterWork: {{restore:() => {{ calls.push('restore'); return true; }}}},
|
|
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
|
|
start:saved => calls.push(['start', saved.number]), announce:message => calls.push(message),
|
|
}});
|
|
(async () => {{
|
|
const result = await controller.start(item);
|
|
process.stdout.write(JSON.stringify({{result,calls}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"result": "started",
|
|
"calls": [
|
|
"restore", "refresh", "warm", ["start", 10],
|
|
"Opened the existing Today item.",
|
|
],
|
|
}
|
|
|
|
|
|
def test_starting_deferred_work_is_single_flight_for_repeated_taps():
|
|
script = f"""
|
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
|
let adds = 0;
|
|
let finishStart;
|
|
const item = {{kind:'issue',repository:'x/y',number:11}};
|
|
const controller = createLaterAndStart({{
|
|
todayWork: {{identity:() => 'issue:x/y:11', add:() => {{ adds += 1; return 'added'; }}, remove:() => true}},
|
|
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
|
|
refresh:() => {{}}, warm:() => {{}}, announce:() => {{}},
|
|
start:() => new Promise(resolve => {{ finishStart = resolve; }}),
|
|
}});
|
|
const first = controller.start(item);
|
|
const second = controller.start(item);
|
|
if (adds !== 1) throw new Error('duplicate admission');
|
|
finishStart('opened');
|
|
(async () => {{
|
|
const results = await Promise.all([first, second]);
|
|
process.stdout.write(JSON.stringify({{adds,results}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {"adds": 1, "results": ["started", "started"]}
|
|
|
|
|
|
def test_starting_blocked_deferred_work_reports_readiness_gate_without_claiming_open():
|
|
script = f"""
|
|
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
|
|
const messages = [];
|
|
const controller = createLaterAndStart({{
|
|
todayWork: {{identity:() => 'issue:x/y:12', add:() => 'added', remove:() => true}},
|
|
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
|
|
refresh:() => {{}}, warm:() => {{}}, start:() => Promise.resolve('gated'),
|
|
announce:message => messages.push(message),
|
|
}});
|
|
(async () => {{
|
|
const result = await controller.start({{kind:'issue',repository:'x/y',number:12}});
|
|
process.stdout.write(JSON.stringify({{result,messages}}));
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"result": "gated",
|
|
"messages": ["Moved to Today. Choose how to handle its blocker before starting."],
|
|
}
|
|
|
|
|
|
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
|
|
script = f"""
|
|
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
|
const calls = [];
|
|
let sessionActive = false;
|
|
const controller = createDetailDefer({{
|
|
laterWork: {{
|
|
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
|
|
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return 'deferred'; }},
|
|
}},
|
|
session: {{ active:() => sessionActive }},
|
|
close:() => calls.push(['close']),
|
|
refresh:() => calls.push(['refresh']),
|
|
focus:() => calls.push(['focus']),
|
|
announce:message => calls.push(['announce', message]),
|
|
formatTime:value => value.toISOString(),
|
|
}});
|
|
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
|
|
const outside = controller.defer(item, 'today');
|
|
sessionActive = true;
|
|
const session = controller.defer(item, 'tomorrow');
|
|
process.stdout.write(JSON.stringify({{outside,session,calls}}));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"outside": True,
|
|
"session": True,
|
|
"calls": [
|
|
["defer", "Read first", "2026-08-08T16:00:00.000Z"],
|
|
["close"],
|
|
["refresh"],
|
|
["announce", "Deferred until 2026-08-08T16:00:00.000Z. It stays unread and unchanged in Gitea."],
|
|
["focus"],
|
|
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
|
|
["refresh"],
|
|
["announce", "Deferred until 2026-08-09T09:00:00.000Z. It stays unread and unchanged in Gitea."],
|
|
],
|
|
}
|
|
|
|
|
|
def test_detail_defer_reports_storage_failure_without_closing_or_refreshing():
|
|
script = f"""
|
|
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
|
const calls = [];
|
|
const controller = createDetailDefer({{
|
|
laterWork: {{
|
|
presetUntil:() => new Date('2026-08-08T16:00:00Z'),
|
|
defer:() => 'unavailable',
|
|
}},
|
|
session: {{active:() => false}},
|
|
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
|
|
focus:() => calls.push('focus'), announce:message => calls.push(message),
|
|
}});
|
|
const saved = controller.defer({{kind:'issue',repository:'stackchain/api',number:17}}, 'today');
|
|
process.stdout.write(JSON.stringify({{saved,calls}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"saved": False,
|
|
"calls": ["Could not save Later on this device."],
|
|
}
|
|
|
|
|
|
def test_detail_defer_completes_checkpointed_today_item_after_saving_later():
|
|
script = f"""
|
|
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
|
const calls = [];
|
|
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
|
|
const controller = createDetailDefer({{
|
|
laterWork: {{
|
|
presetUntil:() => new Date('2026-08-09T09:00:00Z'),
|
|
defer:(saved, until) => {{ calls.push(['defer', saved.title, until.toISOString()]); return 'deferred'; }},
|
|
restore:() => calls.push(['restore']),
|
|
}},
|
|
session: {{active:() => true, checkpointed:saved => saved === item}},
|
|
continueSession:saved => {{ calls.push(['continue', saved.title]); return true; }},
|
|
close:() => calls.push(['close']), refresh:() => calls.push(['refresh']),
|
|
focus:() => calls.push(['focus']), announce:message => calls.push(['announce', message]),
|
|
}});
|
|
const saved = controller.defer(item, 'tomorrow');
|
|
process.stdout.write(JSON.stringify({{saved,calls}}));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"saved": True,
|
|
"calls": [
|
|
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
|
|
["continue", "Read first"],
|
|
],
|
|
}
|
|
|
|
|
|
def test_detail_defer_rolls_back_later_when_today_cannot_be_removed():
|
|
script = f"""
|
|
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
|
const calls = [];
|
|
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
|
|
const controller = createDetailDefer({{
|
|
laterWork: {{
|
|
presetUntil:() => new Date('2026-08-09T09:00:00Z'),
|
|
defer:() => {{ calls.push('defer'); return 'deferred'; }},
|
|
restore:saved => {{ calls.push(['restore', saved.number]); return true; }},
|
|
}},
|
|
session: {{active:() => true, checkpointed:() => true}},
|
|
continueSession:() => {{ calls.push('continue'); return false; }},
|
|
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
|
|
focus:() => calls.push('focus'), announce:message => calls.push(['announce', message]),
|
|
}});
|
|
const saved = controller.defer(item, 'tomorrow');
|
|
process.stdout.write(JSON.stringify({{saved,calls}}));
|
|
"""
|
|
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"saved": False,
|
|
"calls": [
|
|
"defer",
|
|
"continue",
|
|
["restore", 17],
|
|
"refresh",
|
|
["announce", "Could not remove this item from Today, so it was restored from Later."],
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/detail-defer.js"></script>' in html
|
|
assert html.count('class="detail-defer"') == 4
|
|
assert html.count('data-detail-defer-preset="today"') == 4
|
|
assert html.count('data-detail-defer-preset="tomorrow"') == 4
|
|
assert 'const detailDefer = createDetailDefer({' in html
|
|
assert 'selectedUpdate || selectedReview || selectedIssue || selectedPull' in html
|
|
assert "detailDefer.defer(item, button.dataset.detailDeferPreset)" in html
|
|
assert ".detail-defer summary, .detail-defer button { min-height:44px;" in html
|
|
assert "fetch(" not in DETAIL_DEFER.read_text()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_checkpointed_detail_defer_completes_today_and_advertises_next_item():
|
|
html = await dashboard()
|
|
|
|
assert "continueSession: item => completeTodayItem(item, {" in html
|
|
assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html
|
|
assert "advance: () => runTodayTransition('complete')" in html
|
|
assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html
|
|
assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html
|
|
assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/later-work.js"></script>' in html
|
|
assert 'data-work-filter="later"' in html
|
|
assert 'data-work-count="later"' in html
|
|
assert 'const laterWork = createLaterWork({' in html
|
|
assert 'getLogin: () => planningOwnerLogin' in html
|
|
assert 'laterWork.partition(lastMyWork,' in html
|
|
assert 'data-later-preset="today"' in html
|
|
assert 'data-later-preset="tomorrow"' in html
|
|
assert 'data-later-restore' in html
|
|
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
|
|
assert "const result = laterWork.defer(item, until)" in html
|
|
assert 'laterWork.restore(item)' in html
|
|
assert '.later-actions button { min-height:44px;' in html
|
|
assert "'Deferred until ' + fmt(until)" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_later_cards_start_exact_item_in_a_resumable_today_session():
|
|
html = await dashboard()
|
|
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
|
|
|
assert '<script src="static/later-and-start.js"></script>' in html
|
|
assert 'data-later-start' in html
|
|
assert '>Start now</button>' in html
|
|
assert 'aria-label="Deferred work actions"' in html
|
|
assert "const laterAndStart = createLaterAndStart({" in html
|
|
assert "laterAndStart.start(item)" in html
|
|
assert "qs('[data-work-filter=\"today\"]').click();" in html
|
|
assert "todayReadiness.run('start', workSession.items(), item)" in html
|
|
assert "selectedWorkFilter === 'later' ? laterActions" in html
|
|
assert ".later-actions button { min-height:44px; width:100%; }" in html
|
|
assert "BASE + 'static/later-and-start.js'" in service_worker
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/later-picker.js"></script>' in html
|
|
assert html.count('<button type="button" data-detail-defer-custom') == 4
|
|
assert 'data-later-custom' in html
|
|
assert 'id="later-picker"' in html
|
|
assert 'role="dialog"' in html
|
|
assert 'type="datetime-local"' in html
|
|
assert 'id="later-picker-timezone"' in html
|
|
assert 'id="later-picker-error"' in html
|
|
assert 'createLaterPicker({' in html
|
|
assert "laterPicker.open(item, button, 'detail')" in html
|
|
assert "laterPicker.open(item, button, 'card')" in html
|
|
assert "detailDefer.deferUntil(item, until, {" in html
|
|
assert "laterWork.defer(item, until)" in html
|
|
assert "fetch(" not in LATER_PICKER.read_text()
|
|
assert '.later-picker-panel' in html
|
|
assert '.later-picker { box-sizing:border-box; width:100%; height:100%;' in html
|
|
assert 'env(safe-area-inset-bottom)' in html
|
|
|
|
|
|
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
|
payload = {
|
|
"issues": [
|
|
{"number": 1, "repository": "stackchain/api", "title": "RC issue",
|
|
"milestone": {"id": 9, "title": "August RC"}},
|
|
{"number": 2, "repository": "stackchain/api", "title": "Unplanned",
|
|
"milestone": None},
|
|
],
|
|
"pull_requests": [
|
|
{"number": 3, "repository": "stackchain/api", "title": "PR"},
|
|
],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const original = {json.dumps(payload)};
|
|
const queue = buildMyWork(original);
|
|
const updated = buildMyWork.replaceIssueMilestone(
|
|
original, 'stackchain/api', 2, {{id:9,title:'August RC'}}
|
|
);
|
|
process.stdout.write(JSON.stringify({{
|
|
rc: buildMyWork.filterMyWork(queue, 'issue', '9').map(item => item.title),
|
|
unplanned: buildMyWork.filterMyWork(queue, 'all', 'unplanned').map(item => item.title),
|
|
options: buildMyWork.milestoneLanes(queue),
|
|
updated: updated.issues[1].milestone,
|
|
original: original.issues[1].milestone,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"rc": ["RC issue"],
|
|
"unplanned": ["Unplanned"],
|
|
"options": [{"id": 9, "title": "August RC"}],
|
|
"updated": {"id": 9, "title": "August RC"},
|
|
"original": None,
|
|
}
|
|
|
|
|
|
def test_issue_milestone_editor_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
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),
|
|
}};
|
|
let calls = [];
|
|
let finish;
|
|
const controller = createIssueSheet({{
|
|
storage,
|
|
fetchJson:(url, options) => {{
|
|
calls.push({{url, options}});
|
|
return new Promise(resolve => {{ finish = resolve; }});
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:17}};
|
|
const first = controller.updateMilestone(item, 9);
|
|
const duplicate = controller.updateMilestone(item, 9);
|
|
const during = controller.loadMilestoneDraft(item);
|
|
finish({{repository:'stackchain/api',number:17,state:'open',milestone:{{id:9,title:'August RC'}}}});
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
|
same:first === duplicate, during, after:controller.loadMilestoneDraft(item), results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["calls"] == [{
|
|
"url": "api/v1/repos/stackchain/api/issues/17/milestone",
|
|
"method": "PATCH", "body": {"milestone_id": 9},
|
|
}]
|
|
assert output["same"] is True
|
|
assert output["during"] == 9
|
|
assert output["after"] is None
|
|
assert output["results"][0]["milestone"] == {"id": 9, "title": "August RC"}
|
|
|
|
|
|
def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
|
|
items = [
|
|
{"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
|
|
{"kind": "pull", "repository": "stackchain/web", "number": 2, "title": "Second"},
|
|
{"kind": "issue", "repository": "stackchain/api", "number": 3, "title": "Third"},
|
|
]
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let items = {json.dumps(items)};
|
|
let filter = 'issue';
|
|
const opened = [];
|
|
const progress = [];
|
|
let finished = 0;
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems: () => items,
|
|
getFilter: () => filter,
|
|
onOpen: item => opened.push(item.title),
|
|
onProgress: state => progress.push(state),
|
|
onFinish: () => {{ finished += 1; }},
|
|
}});
|
|
session.start();
|
|
items = [items[2], items[1], items[0]];
|
|
session.reconcile();
|
|
session.previous();
|
|
items = items.filter(item => item.number !== 3);
|
|
session.complete();
|
|
items = [];
|
|
session.complete();
|
|
process.stdout.write(JSON.stringify({{opened, progress, finished, active:session.active()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["opened"] == ["First", "Third", "First"]
|
|
assert output["progress"] == [
|
|
{"index": 1, "total": 2, "can_previous": False, "can_next": True},
|
|
{"index": 2, "total": 2, "can_previous": True, "can_next": False},
|
|
{"index": 1, "total": 2, "can_previous": False, "can_next": True},
|
|
{"index": 1, "total": 1, "can_previous": False, "can_next": False},
|
|
]
|
|
assert output["finished"] == 1
|
|
assert output["active"] is False
|
|
|
|
|
|
def test_mobile_work_session_completion_finishes_when_current_item_is_last():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = [{{kind:'pull',repository:'stackchain/api',number:9,is_review:true}}];
|
|
let finished = 0;
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems: () => items, getFilter: () => 'review', onOpen: () => {{}},
|
|
onProgress: () => {{}}, onFinish: () => {{ finished += 1; }},
|
|
}});
|
|
session.start();
|
|
session.complete();
|
|
process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
|
|
|
|
|
def test_today_timer_restores_wall_clock_time_once_and_excludes_pauses():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
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),
|
|
}};
|
|
let now = 1000;
|
|
const create = () => createTodayTimer({{
|
|
storage, getLogin:() => 'Timmy', now:() => now,
|
|
}});
|
|
const first = create();
|
|
first.activate('issue:stackchain/dashboard:577:');
|
|
now += 10 * 60 * 1000;
|
|
const beforeReload = first.snapshot();
|
|
const restored = create();
|
|
const afterReload = restored.snapshot();
|
|
restored.pause();
|
|
now += 5 * 60 * 1000;
|
|
const whilePaused = restored.snapshot();
|
|
restored.resume();
|
|
now += 2 * 60 * 1000;
|
|
const afterResume = restored.snapshot();
|
|
process.stdout.write(JSON.stringify({{
|
|
api:typeof createTodayTimer,
|
|
beforeReload, afterReload, whilePaused, afterResume,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], capture_output=True, text=True
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"api": "function",
|
|
"beforeReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
|
|
"afterReload": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": True},
|
|
"whilePaused": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 600000, "running": False},
|
|
"afterResume": {"identity": "issue:stackchain/dashboard:577:", "elapsed_ms": 720000, "running": True},
|
|
}
|
|
|
|
|
|
def test_today_timer_reconciles_long_away_time_once_and_keeps_short_switches_seamless():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
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),
|
|
}};
|
|
let login = 'timmy';
|
|
let now = 0;
|
|
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
|
timer.activate('issue:repo:591:');
|
|
now = 60000;
|
|
timer.markAway();
|
|
now = 4 * 60000;
|
|
const short = timer.reconcileInterruption();
|
|
now = 5 * 60000;
|
|
timer.markAway();
|
|
now = 11 * 60000;
|
|
const pending = timer.reconcileInterruption();
|
|
now = 12 * 60000;
|
|
const frozen = timer.snapshot();
|
|
const restored = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).pendingInterruption();
|
|
const counted = timer.resolveInterruption('count');
|
|
const afterCount = timer.snapshot();
|
|
const duplicate = timer.resolveInterruption('count');
|
|
now = 13 * 60000;
|
|
timer.markAway();
|
|
now = 19 * 60000;
|
|
timer.reconcileInterruption();
|
|
const excluded = timer.resolveInterruption('exclude');
|
|
const afterExclude = timer.snapshot();
|
|
login = 'alexander';
|
|
const isolated = timer.pendingInterruption();
|
|
process.stdout.write(JSON.stringify({{
|
|
short,pending,frozen,restored,counted,afterCount,duplicate,excluded,afterExclude,isolated,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"short": None,
|
|
"pending": {
|
|
"identity": "issue:repo:591:",
|
|
"away_ms": 360000,
|
|
},
|
|
"frozen": {
|
|
"identity": "issue:repo:591:",
|
|
"elapsed_ms": 660000,
|
|
"running": False,
|
|
},
|
|
"restored": {
|
|
"identity": "issue:repo:591:",
|
|
"away_ms": 360000,
|
|
},
|
|
"counted": True,
|
|
"afterCount": {
|
|
"identity": "issue:repo:591:",
|
|
"elapsed_ms": 660000,
|
|
"running": True,
|
|
},
|
|
"duplicate": False,
|
|
"excluded": True,
|
|
"afterExclude": {
|
|
"identity": "issue:repo:591:",
|
|
"elapsed_ms": 720000,
|
|
"running": True,
|
|
},
|
|
"isolated": None,
|
|
}
|
|
|
|
|
|
def test_today_timer_pauses_for_attention_and_restores_only_automatic_running_state():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
let login = 'timmy';
|
|
let now = 0;
|
|
const timer = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}});
|
|
timer.activate('issue:r:595:');
|
|
now = 60000;
|
|
const entered = timer.beginAttention();
|
|
now = 5 * 60000;
|
|
const duplicate = timer.beginAttention();
|
|
const frozen = timer.snapshot();
|
|
const restored = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}}).attentionInterruption();
|
|
now = 11 * 60000;
|
|
const returned = timer.returnFromAttention();
|
|
now = 12 * 60000;
|
|
const resumed = timer.snapshot();
|
|
timer.pause();
|
|
now = 13 * 60000;
|
|
const enteredPaused = timer.beginAttention();
|
|
now = 20 * 60000;
|
|
const returnedPaused = timer.returnFromAttention();
|
|
const stillPaused = timer.snapshot();
|
|
login = 'alexander';
|
|
const isolated = timer.attentionInterruption();
|
|
process.stdout.write(JSON.stringify({{
|
|
entered,duplicate,frozen,restored,returned,resumed,
|
|
enteredPaused,returnedPaused,stillPaused,isolated,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"entered": {"identity": "issue:r:595:", "resume": True},
|
|
"duplicate": {"identity": "issue:r:595:", "resume": True},
|
|
"frozen": {"identity": "issue:r:595:", "elapsed_ms": 60000, "running": False},
|
|
"restored": {"identity": "issue:r:595:", "resume": True},
|
|
"returned": {"identity": "issue:r:595:", "resumed": True},
|
|
"resumed": {"identity": "issue:r:595:", "elapsed_ms": 120000, "running": True},
|
|
"enteredPaused": {"identity": "issue:r:595:", "resume": False},
|
|
"returnedPaused": {"identity": "issue:r:595:", "resumed": False},
|
|
"stillPaused": {"identity": "issue:r:595:", "elapsed_ms": 120000, "running": False},
|
|
"isolated": None,
|
|
}
|
|
|
|
|
|
def test_today_interruption_prompt_restores_and_resolves_the_pending_mobile_decision():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
let now = 0;
|
|
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
|
const sheet = {{hidden:true}};
|
|
const description = {{textContent:''}};
|
|
let resolved = 0;
|
|
const prompt = createTodayTimer.createInterruptionPrompt({{
|
|
timer, sheet, description,
|
|
getItemLabel: identity => identity === 'issue:r:7:' ? 'Fix mobile timer' : '',
|
|
onResolved: () => resolved++,
|
|
}});
|
|
timer.activate('issue:r:7:');
|
|
now = 60000;
|
|
prompt.background();
|
|
now = 7 * 60000;
|
|
const opened = prompt.foreground();
|
|
const shown = {{hidden:sheet.hidden, text:description.textContent}};
|
|
const restored = prompt.restore();
|
|
const excluded = prompt.resolve('exclude');
|
|
const closed = sheet.hidden;
|
|
process.stdout.write(JSON.stringify({{opened,shown,restored,excluded,closed,resolved}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"opened": True,
|
|
"shown": {
|
|
"hidden": False,
|
|
"text": "Fix mobile timer · away for 6 minutes",
|
|
},
|
|
"restored": True,
|
|
"excluded": True,
|
|
"closed": True,
|
|
"resolved": 1,
|
|
}
|
|
|
|
|
|
def test_today_interruption_detection_is_idempotent_and_fails_closed_for_invalid_state():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
let now = 0;
|
|
const timer = createTodayTimer({{storage,getLogin:()=>'timmy',now:()=>now}});
|
|
timer.activate('issue:r:1:');
|
|
now = 60000;
|
|
timer.markAway();
|
|
now = 120000;
|
|
timer.markAway();
|
|
now = 6 * 60000;
|
|
const originalCheckpoint = timer.reconcileInterruption();
|
|
timer.resolveInterruption('exclude');
|
|
timer.pause();
|
|
now = 20 * 60000;
|
|
const paused = timer.markAway();
|
|
const key = 'stackchain.today-timer.v1.timmy';
|
|
const invalid = JSON.parse(values.get(key));
|
|
invalid.pending_interruption = {{identity:7,away_ms:'bad'}};
|
|
invalid.away_at = now + 1000;
|
|
values.set(key, JSON.stringify(invalid));
|
|
const malformed = timer.reconcileInterruption();
|
|
process.stdout.write(JSON.stringify({{originalCheckpoint,paused,malformed,pending:timer.pendingInterruption()}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"originalCheckpoint": {"identity": "issue:r:1:", "away_ms": 300000},
|
|
"paused": False,
|
|
"malformed": None,
|
|
"pending": None,
|
|
}
|
|
|
|
|
|
def test_today_timer_switches_items_retains_elapsed_time_and_is_account_bound():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
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),
|
|
}};
|
|
let login = 'timmy';
|
|
let now = 0;
|
|
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
|
timer.activate('issue:repo:1:');
|
|
now = 90000;
|
|
timer.activate('pull:repo:2:');
|
|
now = 120000;
|
|
const first = timer.snapshot('issue:repo:1:');
|
|
const second = timer.snapshot();
|
|
timer.stop();
|
|
now = 300000;
|
|
const stopped = timer.snapshot();
|
|
login = 'alexander';
|
|
const other = createTodayTimer({{storage,getLogin:() => login,now:() => now}}).snapshot();
|
|
process.stdout.write(JSON.stringify({{first,second,stopped,other}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"first": {"identity": "issue:repo:1:", "elapsed_ms": 90000, "running": False},
|
|
"second": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": True},
|
|
"stopped": {"identity": "pull:repo:2:", "elapsed_ms": 30000, "running": False},
|
|
"other": {"identity": "", "elapsed_ms": 0, "running": False},
|
|
}
|
|
|
|
|
|
def test_today_timer_totals_elapsed_work_for_live_capacity_without_cross_account_leakage():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
let login = 'timmy';
|
|
let now = 0;
|
|
const timer = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}});
|
|
timer.activate('issue:r:1:');
|
|
now = 10 * 60000;
|
|
timer.activate('issue:r:2:');
|
|
now = 25 * 60000;
|
|
const timmy = timer.totalElapsed();
|
|
login = 'alexander';
|
|
const isolated = timer.totalElapsed();
|
|
process.stdout.write(JSON.stringify({{timmy,isolated}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {"timmy": 25 * 60000, "isolated": 0}
|
|
|
|
|
|
def test_today_timer_view_renders_live_budget_risk_and_replan_action():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
const progress = {{textContent:''}};
|
|
const adjust = {{hidden:true}};
|
|
const toggle = {{hidden:false,textContent:'',setAttribute(){{}}}};
|
|
const snapshot = {{identity:'issue:r:1:',elapsed_ms:45*60000,running:true}};
|
|
const view = createTodayTimer.createView({{
|
|
timer:{{snapshot:()=>snapshot,totalElapsed:()=>45*60000}}, isActive:()=>true,
|
|
queryAll:selector => selector.includes('progress') ? [progress] :
|
|
selector.includes('adjust-plan') ? [adjust] :
|
|
selector === '[data-work-session-timer-toggle]' ? [toggle] : [],
|
|
formatEstimate:minutes => minutes + 'm',
|
|
getRunway:() => ({{current_minutes:30,remaining_minutes:90,future_minutes:60,capacity_minutes:100}}),
|
|
}});
|
|
view.update({{index:1,total:2}});
|
|
process.stdout.write(JSON.stringify({{text:progress.textContent,adjustHidden:adjust.hidden}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"text": "Item 1 of 2 · 45:00 / 30m · 15m over estimate · Today projected 5m over capacity",
|
|
"adjustHidden": False,
|
|
}
|
|
|
|
|
|
def test_today_timer_view_keeps_active_item_and_controls_visible_in_mobile_hud():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
class Element {{
|
|
constructor() {{ this.hidden=true; this.textContent=''; this.attributes={{}}; this.listeners={{}}; }}
|
|
setAttribute(name,value) {{ this.attributes[name]=value; }}
|
|
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
|
click() {{ this.listeners.click?.(); }}
|
|
}}
|
|
const hud = new Element();
|
|
const title = new Element();
|
|
const progress = new Element();
|
|
const adjust = new Element();
|
|
const toggle = new Element();
|
|
let running = true;
|
|
const calls = [];
|
|
const selectors = {{
|
|
'[data-mobile-today-hud]':[hud],
|
|
'[data-mobile-today-open]':[title],
|
|
'[data-mobile-today-toggle]':[toggle],
|
|
'[data-work-session-progress]':[progress],
|
|
'[data-work-session-adjust-plan]':[adjust],
|
|
'[data-work-session-timer-toggle]':[toggle],
|
|
}};
|
|
const view = createTodayTimer.createView({{
|
|
timer:{{
|
|
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:10*60000,running}}),
|
|
totalElapsed:()=>10*60000,
|
|
pause:()=>{{running=false;calls.push('pause');return true;}},
|
|
resume:()=>{{running=true;calls.push('resume');return true;}},
|
|
stop:()=>{{running=false;return true;}},
|
|
}},
|
|
isActive:()=>true,
|
|
queryAll:selector => selectors[selector] || [],
|
|
formatEstimate:minutes => minutes + 'm',
|
|
getItem:identity => identity === 'issue:r:1:' ? {{title:'Ship the mobile Today HUD'}} : null,
|
|
onReopen:identity => calls.push('open:' + identity),
|
|
}});
|
|
view.update({{index:1,total:2}},{{current_minutes:30,future_minutes:20,capacity_minutes:60}});
|
|
const active = {{hidden:hud.hidden,title:title.textContent,progress:progress.textContent,toggle:toggle.textContent}};
|
|
title.click();
|
|
toggle.click();
|
|
const paused = {{toggle:toggle.textContent,pressed:toggle.attributes['aria-pressed']}};
|
|
toggle.click();
|
|
view.finish();
|
|
process.stdout.write(JSON.stringify({{active,paused,calls,finishedHidden:hud.hidden}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"active": {
|
|
"hidden": False,
|
|
"title": "Ship the mobile Today HUD",
|
|
"progress": "Item 1 of 2 · 10:00 / 30m · 40m remaining",
|
|
"toggle": "Pause timer",
|
|
},
|
|
"paused": {"toggle": "Resume timer", "pressed": "true"},
|
|
"calls": ["open:issue:r:1:", "pause", "resume"],
|
|
"finishedHidden": True,
|
|
}
|
|
|
|
|
|
def test_today_timer_view_reopens_current_item_without_resuming_a_paused_timer():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
const calls = [];
|
|
const timer = {{
|
|
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:60000,running:false}}),
|
|
totalElapsed:()=>60000,
|
|
activate:identity=>{{calls.push(identity);return true;}},
|
|
stop:()=>true,
|
|
}};
|
|
const view = createTodayTimer.createView({{
|
|
timer, isActive:()=>true, queryAll:()=>[], formatEstimate:String,
|
|
}});
|
|
view.open('issue:r:1:', true);
|
|
view.open('issue:r:2:', true);
|
|
process.stdout.write(JSON.stringify(calls));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == ["issue:r:2:"]
|
|
|
|
|
|
def test_today_budget_replan_pauses_once_and_restores_the_prior_timer_state():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
const calls = [];
|
|
let running = true;
|
|
const timer = {{
|
|
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:45*60000,running}}),
|
|
pause:()=>{{calls.push('pause');running=false;return true;}},
|
|
resume:()=>{{calls.push('resume');running=true;return true;}},
|
|
}};
|
|
const handoff = createTodayTimer.createBudgetReplan({{timer,openPlan:state=>calls.push('open:' + state.identity + ':' + Math.ceil(state.elapsed_ms/60000))}});
|
|
const opened = handoff.open();
|
|
const duplicate = handoff.open();
|
|
const restored = handoff.restore();
|
|
running = false;
|
|
const pausedOpen = handoff.open();
|
|
const pausedRestore = handoff.restore();
|
|
process.stdout.write(JSON.stringify({{opened,duplicate,restored,pausedOpen,pausedRestore,calls}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"opened": True,
|
|
"duplicate": False,
|
|
"restored": True,
|
|
"pausedOpen": True,
|
|
"pausedRestore": True,
|
|
"calls": ["pause", "open:issue:r:1::45", "resume", "open:issue:r:1::45"],
|
|
}
|
|
|
|
|
|
def test_today_timer_can_be_initialized_before_operator_identity_is_restored():
|
|
script = f"""
|
|
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
|
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),
|
|
}};
|
|
let login = '';
|
|
let now = 100;
|
|
const timer = createTodayTimer({{storage,getLogin:() => login,now:() => now}});
|
|
login = 'timmy';
|
|
const activated = timer.activate('issue:repo:1:');
|
|
now = 1100;
|
|
process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"activated": True,
|
|
"snapshot": {"identity": "issue:repo:1:", "elapsed_ms": 1000, "running": True},
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_today_live_budget_replan_is_wired_into_every_mobile_session_control():
|
|
html = await dashboard()
|
|
dashboard_source = TODAY_TIMER.with_name("dashboard.js").read_text()
|
|
|
|
assert html.count('<nav class="work-session-nav" aria-label="Work session" hidden>') == 4
|
|
timer_source = TODAY_TIMER.read_text()
|
|
assert "document.createElement('button')" in timer_source
|
|
assert "button.textContent = 'Adjust remaining plan'" in timer_source
|
|
assert "todayWork.runway(todayMyWork, state.index - 1)" in dashboard_source
|
|
assert 'timer.totalElapsed()' in timer_source
|
|
assert 'createTodayBudgetReplan({' in timer_source
|
|
assert "button.addEventListener('click', () => replan.open())" in timer_source
|
|
assert 'if (sheet.hidden && replan.restore()) render();' in timer_source
|
|
assert '.work-session-nav button { min-height:44px;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_today_timer_is_wired_into_every_mobile_session_control():
|
|
html = await dashboard()
|
|
timer_source = TODAY_TIMER.read_text()
|
|
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
|
|
|
assert html.count('<button type="button" data-work-session-timer-toggle>') == 4
|
|
assert 'createTodayTimer({' in html
|
|
assert 'createTodayTimerView({' in html
|
|
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
|
|
assert "if (active && current.identity !== identity) timer.activate(identity)" in timer_source
|
|
assert "else if (!active) timer.stop()" in timer_source
|
|
assert "setInterval(timerView.render, 1000)" in recap_source
|
|
assert 'timer.pause()' in timer_source
|
|
assert 'timer.resume()' in timer_source
|
|
assert "elapsed(snapshot.elapsed_ms)" in timer_source
|
|
assert "' / ' + formatEstimate(liveRunway.current_minutes)" in timer_source
|
|
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_reconciles_long_today_interruptions_in_a_touch_safe_sheet():
|
|
html = await dashboard()
|
|
|
|
assert 'id="today-interruption-sheet"' in html
|
|
assert 'id="today-interruption-description"' in html
|
|
assert 'data-today-interruption="count"' in html
|
|
assert 'data-today-interruption="exclude"' in html
|
|
assert "const interruptionPrompt = createTodayInterruptionPrompt({" in html
|
|
assert "if (document.hidden) interruptionPrompt.background();" in html
|
|
assert "else interruptionPrompt.foreground();" in html
|
|
assert "window.addEventListener('pagehide', () => interruptionPrompt.background());" in html
|
|
assert "interruptionPrompt.restore();" in html
|
|
assert "interruptionPrompt.resolve(button.dataset.todayInterruption)" in html
|
|
assert ".today-interruption-panel { box-sizing:border-box; width:min(620px,100%);" in html
|
|
assert "padding-bottom:calc(18px + env(safe-area-inset-bottom))" in html
|
|
assert ".today-interruption-actions button { min-height:44px; width:100%; }" in html
|
|
|
|
|
|
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let saves = 0;
|
|
const checkpoint = {{
|
|
read: () => null,
|
|
save: () => {{ saves += 1; return true; }},
|
|
clear: () => true,
|
|
}};
|
|
const items = [1, 2].map(number => ({{
|
|
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
|
}}));
|
|
const opened = [];
|
|
const progress = [];
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems: () => items, getFilter: () => 'all', checkpoint,
|
|
onOpen: item => opened.push(item.number),
|
|
onProgress: state => progress.push(state.index), onFinish: () => {{}},
|
|
}});
|
|
session.start();
|
|
session.next();
|
|
const savesBeforeReopen = saves;
|
|
const reopened = session.reopen();
|
|
process.stdout.write(JSON.stringify({{
|
|
reopened, opened, progress, savesBeforeReopen, savesAfterReopen:saves,
|
|
todayActive:session.checkpointed(),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], capture_output=True, text=True
|
|
)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"reopened": True,
|
|
"opened": [1, 2, 2],
|
|
"progress": [1, 2, 2],
|
|
"savesBeforeReopen": 2,
|
|
"savesAfterReopen": 2,
|
|
"todayActive": True,
|
|
}
|
|
|
|
|
|
def test_today_session_checkpoint_resumes_current_item_after_reload():
|
|
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 items = [1, 2, 3].map(number => ({{
|
|
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
|
}}));
|
|
const opened = [];
|
|
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
|
storage, getLogin: () => 'timmy',
|
|
}});
|
|
const first = buildMyWork.createWorkSession({{
|
|
getItems: () => items, getFilter: () => 'all',
|
|
checkpoint: firstCheckpoint,
|
|
onOpen: item => opened.push('first:' + item.number),
|
|
onProgress: () => {{}}, onFinish: () => {{}},
|
|
}});
|
|
first.start();
|
|
first.next();
|
|
|
|
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
|
storage, getLogin: () => 'timmy',
|
|
}});
|
|
const resumed = buildMyWork.createWorkSession({{
|
|
getItems: () => items, getFilter: () => 'all',
|
|
checkpoint: secondCheckpoint,
|
|
onOpen: item => opened.push('resumed:' + item.number),
|
|
onProgress: () => {{}}, onFinish: () => {{}},
|
|
}});
|
|
const available = resumed.resumable();
|
|
const didResume = resumed.resume();
|
|
process.stdout.write(JSON.stringify({{
|
|
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"available": True,
|
|
"didResume": True,
|
|
"opened": ["first:1", "first:2", "resumed:2"],
|
|
"checkpoint": {
|
|
"version": 1,
|
|
"login": "timmy",
|
|
"identity": "issue:stackchain/dashboard:2:",
|
|
"index": 1,
|
|
},
|
|
}
|
|
|
|
|
|
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let login = 'timmy';
|
|
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 checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
|
|
getFilter: () => 'all', checkpoint,
|
|
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
|
|
}});
|
|
session.start();
|
|
login = 'alexander';
|
|
const visibleToOtherAccount = session.resumable();
|
|
const retainedForOwner = values.size;
|
|
login = 'timmy';
|
|
session.end();
|
|
process.stdout.write(JSON.stringify({{
|
|
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
|
|
}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"visibleToOtherAccount": False,
|
|
"retainedForOwner": 1,
|
|
"active": False,
|
|
"stored": 0,
|
|
}
|
|
|
|
|
|
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
|
|
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
|
|
}})]]);
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems:() => [], getFilter:() => 'all', checkpoint,
|
|
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
|
|
}});
|
|
const resumed = session.resume();
|
|
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
|
|
|
|
|
|
def test_today_session_keeps_working_when_checkpoint_storage_fails():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let errors = 0;
|
|
const opened = [];
|
|
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
|
|
storage:{{
|
|
getItem:() => null,
|
|
setItem:() => {{ throw new Error('quota'); }},
|
|
removeItem:() => {{ throw new Error('quota'); }},
|
|
}},
|
|
getLogin:() => 'timmy',
|
|
onError:() => {{ errors += 1; }},
|
|
}});
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
|
|
getFilter:() => 'all', checkpoint,
|
|
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
|
|
}});
|
|
session.start();
|
|
session.next();
|
|
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
|
|
html = await dashboard()
|
|
|
|
assert 'id="start-work-session"' in html
|
|
assert html.count('class="work-session-nav"') == 4
|
|
assert html.count('<span class="small" aria-live="polite" data-work-session-progress>') == 4
|
|
assert html.count('<button type="button" data-work-session-previous>') == 4
|
|
assert html.count('<button type="button" data-work-session-next>') == 4
|
|
assert '.work-session-nav button { min-height:44px;' in html
|
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
|
assert 'aria-live="polite" data-work-session-progress' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
|
html = await dashboard()
|
|
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
|
|
|
assert 'id="resume-today-session"' in html
|
|
assert 'id="end-today-session"' in html
|
|
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
|
|
assert 'getLogin: () => confirmedOwnerLogin' in html
|
|
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
|
|
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
|
|
assert "qs('#resume-today-session').addEventListener('click'" in html
|
|
assert "qs('#end-today-session').addEventListener('click'" in html
|
|
assert "workSession.resume(item)" in html
|
|
assert "runTodayTransition('resume')" in html
|
|
assert "workSession.end()" in recap_source
|
|
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
|
|
html = await dashboard()
|
|
|
|
assert 'const workSession = createWorkSession({' in html
|
|
assert 'function openWorkSessionItem(item)' in html
|
|
assert "qs('#start-work-session').addEventListener('click'" in html
|
|
assert "document.querySelectorAll('[data-work-session-previous]')" in html
|
|
assert "document.querySelectorAll('[data-work-session-next]')" in html
|
|
assert "runTodayTransition('complete')" in html
|
|
assert 'workSession.reconcile();' in html
|
|
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
|
|
assert opener in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
|
|
html = await dashboard()
|
|
|
|
assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
|
|
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
|
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
|
"qs('#close-pull-sheet').addEventListener", 1
|
|
)[0]
|
|
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
|
|
assert "workSession.active() ? await runTodayTransition('complete') : null" in close_handler
|
|
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
|
|
"await runTodayTransition('complete')"
|
|
)
|
|
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
|
|
"await runTodayTransition('complete')"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_releasing_checkpointed_issue_completes_today_through_readiness_transition():
|
|
html = await dashboard()
|
|
|
|
assert "workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'" in html
|
|
release_handler = html.split("qs('#release-issue').addEventListener('click'", 1)[1].split(
|
|
"qs('#load-issue-handoff').addEventListener", 1
|
|
)[0]
|
|
assert "const continuingSession = workSession.checkpointed(releasing);" in release_handler
|
|
assert "await completeOwnershipExitToday(releasing)" in release_handler
|
|
assert "paintMyWork(lastContextSnapshot);" in release_handler
|
|
assert release_handler.index("await issueController.release") < release_handler.index(
|
|
"await completeOwnershipExitToday(releasing)"
|
|
)
|
|
assert release_handler.index("buildMyWork.removeIssue") < release_handler.index(
|
|
"await completeOwnershipExitToday(releasing)"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_handing_off_checkpointed_issue_completes_today_through_readiness_transition():
|
|
html = await dashboard()
|
|
|
|
assert "workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'" in html
|
|
handoff_handler = html.split("qs('#confirm-issue-handoff').addEventListener('click'", 1)[1].split(
|
|
"qs('#close-issue').addEventListener", 1
|
|
)[0]
|
|
assert "const continuingSession = workSession.checkpointed(handingOff);" in handoff_handler
|
|
assert "await completeOwnershipExitToday(handingOff)" in handoff_handler
|
|
assert "paintMyWork(lastContextSnapshot);" in handoff_handler
|
|
assert handoff_handler.index("await issueController.handoff") < handoff_handler.index(
|
|
"await completeOwnershipExitToday(handingOff)"
|
|
)
|
|
assert handoff_handler.index("buildMyWork.removeIssue") < handoff_handler.index(
|
|
"await completeOwnershipExitToday(handingOff)"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next():
|
|
html = await dashboard()
|
|
|
|
assert "qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';" in html
|
|
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
|
"qs('#keep-update-unread').addEventListener", 1
|
|
)[0]
|
|
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
|
|
assert "const continuingSession = workSession.active();" in merge_handler
|
|
assert "workSession.active() ? await runTodayTransition('complete') : null" in merge_handler
|
|
assert "merged. Next work item opened." in merge_handler
|
|
assert merge_handler.index("await pullController.merge") < merge_handler.index(
|
|
"await runTodayTransition('complete')"
|
|
)
|
|
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
|
|
"await runTodayTransition('complete')"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
|
html = await dashboard()
|
|
recap_source = TODAY_TIMER.with_name("today-recap.js").read_text()
|
|
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
|
"qs('#keep-update-unread').addEventListener", 1
|
|
)[0]
|
|
|
|
assert "else if (!continuingSession)" in merge_handler
|
|
assert "merging.key + ' merged.'" in merge_handler
|
|
assert "else if (continuingSession" not in merge_handler
|
|
assert "onFinish: () =>" in html
|
|
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in recap_source
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
|
|
html = await dashboard()
|
|
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
|
"qs('#keep-update-unread').addEventListener", 1
|
|
)[0]
|
|
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
|
|
|
|
assert "lastMyWork = lastMyWork.filter" in success_path
|
|
assert "runTodayTransition('complete')" in success_path
|
|
assert "lastMyWork = lastMyWork.filter" not in failure_path
|
|
assert "workSession.complete()" not in failure_path
|
|
assert "The pull request remains in My Work; refresh and retry." in failure_path
|
|
assert "button.disabled = false;" in failure_path
|
|
|
|
|
|
def test_work_session_can_start_at_a_newly_created_item():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'stackchain/dashboard', number}}));
|
|
const opened = [];
|
|
const session = buildMyWork.createWorkSession({{
|
|
getItems: () => items,
|
|
getFilter: () => 'all',
|
|
getMilestone: () => '',
|
|
onOpen: item => opened.push(item.number),
|
|
onProgress: () => {{}},
|
|
onFinish: () => {{}},
|
|
}});
|
|
const started = session.start(items[2]);
|
|
process.stdout.write(JSON.stringify({{started, opened}}));
|
|
"""
|
|
|
|
output = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout
|
|
assert json.loads(output) == {"started": True, "opened": [3]}
|
|
|
|
|
|
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, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
|
|
}
|
|
|
|
|
|
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let calls = 0;
|
|
let release;
|
|
const pages = [];
|
|
const states = [];
|
|
const statuses = [];
|
|
const pager = buildMyWork.createWorkPager({{
|
|
load: (stream, page) => {{
|
|
calls += 1;
|
|
return new Promise(resolve => {{ release = () => resolve({{
|
|
stream, page, total: 51, has_more: false,
|
|
items: [
|
|
{{id:1, title:'shared', work_reasons:['review_requested']}},
|
|
{{id:51, title:'older', work_reasons:['review_requested']}}
|
|
],
|
|
}}); }});
|
|
}},
|
|
onItems: (stream, items) => states.push({{stream, items}}),
|
|
onPagination: pagination => pages.push(pagination),
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
pager.reset({{review:{{page:1,total:51,has_more:true}}}});
|
|
const existing = [{{id:1,title:'shared',work_reasons:['assigned_to_me']}}];
|
|
const first = pager.loadMore('review', existing);
|
|
const duplicate = pager.loadMore('review', existing);
|
|
release();
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, pages, 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"] == [{
|
|
"stream": "review",
|
|
"items": [
|
|
{"id": 1, "title": "shared", "work_reasons": ["assigned_to_me", "review_requested"]},
|
|
{"id": 51, "title": "older", "work_reasons": ["review_requested"]},
|
|
],
|
|
}]
|
|
assert output["pages"][-1]["review"] == {"page": 2, "total": 51, "has_more": False}
|
|
assert output["statuses"] == [
|
|
"Loading older review requests…", "51 of 51 review requests loaded."
|
|
]
|
|
assert output["results"] == [True, False]
|
|
|
|
|
|
def test_work_pager_keeps_items_and_retries_same_page_after_failure():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const requested = [];
|
|
const states = [];
|
|
const statuses = [];
|
|
const pager = buildMyWork.createWorkPager({{
|
|
load: async (stream, page) => {{ requested.push([stream,page]); throw new Error('offline'); }},
|
|
onItems: (stream, items) => states.push(items),
|
|
onPagination: () => {{}},
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
pager.reset({{issue:{{page:2,total:125,has_more:true}}}});
|
|
pager.loadMore('issue', [{{id:1}}]).then(result =>
|
|
pager.loadMore('issue', [{{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": [["issue", 3], ["issue", 3]],
|
|
"states": [],
|
|
"statuses": [
|
|
"Loading older issues…", "Could not load older issues. Retry.",
|
|
"Loading older issues…", "Could not load older issues. Retry.",
|
|
],
|
|
"result": False,
|
|
"retry": False,
|
|
}
|
|
|
|
|
|
def test_find_work_claim_is_single_flight_and_removes_only_confirmed_issue():
|
|
script = f"""
|
|
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
|
let calls = 0;
|
|
let release;
|
|
const states = [];
|
|
const statuses = [];
|
|
const pages = [];
|
|
const controller = createFindWork({{
|
|
fetchJson: (url, options) => {{
|
|
calls += 1;
|
|
return new Promise(resolve => {{ release = () => resolve({{
|
|
id:17,number:7,title:'Available',repository:'stackchain/api',
|
|
state:'open',labels:[],assignees:['timmy'],url:'https://forge.example/issues/7'
|
|
}}); }});
|
|
}},
|
|
onItems: items => states.push(items),
|
|
onPagination: page => pages.push(page),
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
controller.reset({{items:[
|
|
{{id:17,number:7,title:'Available',repository:'stackchain/api'}},
|
|
{{id:18,number:8,title:'Other',repository:'stackchain/web'}}
|
|
],page:1,total:2,has_more:false}});
|
|
const item = controller.items()[0];
|
|
const first = controller.claim(item);
|
|
const duplicate = controller.claim(item);
|
|
release();
|
|
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls,states,statuses,pages,results,remaining:controller.items()
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == 1
|
|
assert [item["number"] for item in output["remaining"]] == [8]
|
|
assert [item["number"] for item in output["states"][-1]] == [8]
|
|
assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
|
|
assert output["pages"][-1] == {"page": 1, "total": 1, "has_more": False}
|
|
assert output["results"][0]["assignees"] == ["timmy"]
|
|
assert output["results"][1]["assignees"] == ["timmy"]
|
|
|
|
|
|
def test_find_work_loads_paginated_results_without_duplicates():
|
|
script = f"""
|
|
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
|
const calls = [];
|
|
const states = [];
|
|
const pages = [];
|
|
const controller = createFindWork({{
|
|
fetchJson: url => {{
|
|
calls.push(url);
|
|
const page = Number(new URL(url, 'https://example.test/').searchParams.get('page'));
|
|
return Promise.resolve(page === 1 ? {{
|
|
items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:2,has_more:true
|
|
}} : {{
|
|
items:[{{id:1,number:1,repository:'stackchain/api'}},{{id:2,number:2,repository:'stackchain/web'}}],
|
|
page:2,total:2,has_more:false
|
|
}});
|
|
}},
|
|
onItems: items => states.push(items),
|
|
onPagination: page => pages.push(page),
|
|
onStatus: () => {{}},
|
|
}});
|
|
controller.load().then(() => controller.loadMore()).then(() =>
|
|
process.stdout.write(JSON.stringify({{calls,states,pages,items:controller.items()}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [
|
|
"api/v1/available-issues?page=1&facets=true",
|
|
"api/v1/available-issues?page=2&facets=true",
|
|
]
|
|
assert [item["id"] for item in output["items"]] == [1, 2]
|
|
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
|
|
|
|
|
def test_find_work_keeps_retained_cards_and_announces_refresh_freshness():
|
|
script = f"""
|
|
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
|
const statuses = [];
|
|
const states = [];
|
|
const responses = [
|
|
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
|
|
stale:true,revalidating:true}},
|
|
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
|
|
stale:true,refresh_failed:true}},
|
|
];
|
|
const controller = createFindWork({{
|
|
fetchJson: () => Promise.resolve(responses.shift()),
|
|
onItems: items => states.push(items),
|
|
onPagination: () => {{}},
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
controller.load().then(() => controller.load()).then(() =>
|
|
process.stdout.write(JSON.stringify({{statuses,states,items:controller.items()}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["statuses"] == [
|
|
"Showing saved available work while the catalog refreshes…",
|
|
"Showing saved available work. Catalog refresh failed; retrying shortly.",
|
|
]
|
|
assert [item["number"] for item in output["items"]] == [1]
|
|
assert len(output["states"]) == 2
|
|
|
|
|
|
def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
|
|
script = f"""
|
|
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
|
const states = [];
|
|
const controller = createFindWork({{
|
|
fetchJson: (url, options) => options?.method === 'PATCH'
|
|
? Promise.resolve({{number:7,repository:'stackchain/api',assignees:['timmy']}})
|
|
: Promise.resolve({{
|
|
items:[
|
|
{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}},
|
|
{{id:18,number:8,title:'Next',repository:'stackchain/web',body:''}}
|
|
],page:2,total:2,has_more:false
|
|
}}),
|
|
onItems: items => states.push(items),
|
|
onPagination: () => {{}},
|
|
onStatus: () => {{}},
|
|
}});
|
|
controller.reset({{
|
|
items:[{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}}],
|
|
page:1,total:2,has_more:true
|
|
}});
|
|
const target = controller.items()[0];
|
|
const opened = controller.togglePreview(target);
|
|
controller.loadMore().then(() => {{
|
|
const afterPagination = controller.isPreviewed(controller.items()[0]);
|
|
return controller.claim(controller.items()[0]).then(() => process.stdout.write(JSON.stringify({{
|
|
opened, afterPagination, afterClaim:controller.isPreviewed(target), states
|
|
}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["opened"] is True
|
|
assert output["afterPagination"] is True
|
|
assert output["afterClaim"] is False
|
|
assert [item["number"] for item in output["states"][-1]] == [8]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
|
html = await dashboard()
|
|
|
|
assert 'id="work-page-status"' in html
|
|
assert 'id="load-more-work"' in html
|
|
assert '>Load older work<' in html
|
|
assert '.load-more-work { min-height:44px;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form():
|
|
html = await dashboard()
|
|
|
|
assert 'id="load-more-issue-repositories"' in html
|
|
assert '>Load more repositories<' in html
|
|
assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
|
|
assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
|
|
assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
|
|
assert 'appendIssueRepositories(payload.items)' in html
|
|
assert '.create-issue-repository-more { min-height:44px;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_capture_requires_explicit_searchable_repository_selection():
|
|
html = await dashboard()
|
|
|
|
assert 'id="create-issue-repository-search" type="search" maxlength="80"' in html
|
|
assert 'placeholder="Search accessible repositories"' in html
|
|
assert 'id="create-issue-repository-results" role="listbox"' in html
|
|
assert '<option value="">Choose repository</option>' in html
|
|
assert "issueCapture.searchRepositories(query)" in html
|
|
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
|
|
assert "renderIssueRepositoryResults(state.items)" in html
|
|
assert "selectIssueCaptureRepository(repository)" in html
|
|
assert "issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value)" in html
|
|
assert '.create-issue-repository-result { min-height:44px;' in html
|
|
assert '.create-issue-repository-picker { min-width:0;' in html
|
|
|
|
|
|
def test_issue_capture_persists_bounded_blockers_and_searches_open_issues():
|
|
script = f"""
|
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values=new Map();const calls=[];
|
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const capture=createIssueCapture({{storage,fetchJson:async url=>{{calls.push(url);return{{items:[
|
|
{{kind:'issue',state:'open',repository:'o/api',number:7,title:'API ready'}},
|
|
{{kind:'pull',state:'open',repository:'o/web',number:8,title:'Not an issue'}},
|
|
{{kind:'issue',state:'closed',repository:'o/old',number:9,title:'Closed'}}
|
|
]}};}}}});
|
|
const blockers=[
|
|
{{repository:'o/api',number:7,title:'API ready'}},
|
|
{{repository:'o/api',number:7,title:'duplicate'}},
|
|
{{repository:'bad',number:2,title:'invalid'}},
|
|
];
|
|
capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
|
|
(async()=>{{const found=await capture.searchBlockers('api');process.stdout.write(JSON.stringify({{draft:capture.loadDraft(),found,calls}}));}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
assert output["draft"]["blockers"] == [
|
|
{"repository": "o/api", "number": 7, "title": "API ready"}
|
|
]
|
|
assert output["found"]["items"] == [
|
|
{"kind": "issue", "state": "open", "repository": "o/api", "number": 7, "title": "API ready"}
|
|
]
|
|
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
|
|
|
|
|
|
def test_issue_capture_persists_selected_owner_and_sends_it_on_creation():
|
|
script = f"""
|
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values=new Map(); const calls=[];
|
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const fetchJson=async (url,options={{}})=>{{calls.push({{url,body:options.body||''}});if(url.endsWith('/issue-assignees'))return [{{login:'alex',name:'Alex'}}];return {{number:18,assignees:['alex']}};}};
|
|
const capture=createIssueCapture({{storage,fetchJson,createOperationId:()=>'owner-op'}});
|
|
capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[],assignee:'alex',assigneeName:'Alex'}});
|
|
(async()=>{{const owners=await capture.loadOwners('o/r');const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{owners,draft,calls}}));}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["owners"] == [{"login": "alex", "name": "Alex"}]
|
|
assert output["draft"]["assignee"] == "alex"
|
|
assert output["draft"]["assigneeName"] == "Alex"
|
|
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
|
|
|
|
|
|
def test_issue_capture_persists_and_sends_explicit_no_owner():
|
|
script = f"""
|
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values=new Map();const calls=[];
|
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const capture=createIssueCapture({{storage,createOperationId:()=>'unowned-op',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
|
|
capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labelIds:[],unassigned:true}});
|
|
(async()=>{{const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{draft,calls}}));}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["draft"]["unassigned"] is True
|
|
assert output["calls"] == [{
|
|
"title": "Backlog capture", "body": "Context", "label_ids": [], "unassigned": True
|
|
}]
|
|
|
|
|
|
def test_issue_capture_persists_estimate_and_completion_intent_for_resumed_drafts():
|
|
script = f"""
|
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values=new Map();
|
|
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
|
const capture=createIssueCapture({{storage,fetchJson:async()=>{{}}}});
|
|
capture.saveDraft({{
|
|
repository:'o/r',title:'Planned work',body:'Context',labelIds:[3],
|
|
estimateMinutes:45,completionIntent:'create-and-start',
|
|
}});
|
|
process.stdout.write(JSON.stringify(capture.loadDraft()));
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["estimateMinutes"] == 45
|
|
assert output["completionIntent"] == "create-and-start"
|
|
|
|
|
|
def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
|
|
script = f"""
|
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values=new Map(); const calls=[];
|
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
const fetchJson=async url=>{{calls.push(url);return [{{id:'bug-report',name:'Bug report',about:'Report a defect',title:'[Bug] ',body:'## Steps\\n1.',labels:['bug','unknown']}}];}};
|
|
const capture=createIssueCapture({{storage,fetchJson}});
|
|
(async()=>{{
|
|
const templates=await capture.loadTemplates('o/r');
|
|
const labels=[{{id:3,name:'bug'}},{{id:4,name:'P1'}}];
|
|
const first=capture.applyTemplate({{repository:'o/r',title:'Camera crashes',body:'Captured on Android',labelIds:[4]}},templates[0],labels);
|
|
const second=capture.applyTemplate(first,{{id:'feature',name:'Feature',body:'## Outcome',labels:[]}},labels);
|
|
capture.saveDraft(second);
|
|
process.stdout.write(JSON.stringify({{templates,first,second,reloaded:capture.loadDraft(),calls}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["calls"] == ["api/v1/repos/o/r/issue-templates"]
|
|
assert output["first"]["title"] == "Camera crashes"
|
|
assert output["first"]["body"] == "Captured on Android\n\n---\n\n## Steps\n1."
|
|
assert output["first"]["labelIds"] == [4, 3]
|
|
assert output["second"]["body"] == "Captured on Android\n\n---\n\n## Outcome"
|
|
assert output["second"]["templateName"] == "Feature"
|
|
assert output["reloaded"]["templateName"] == "Feature"
|
|
assert output["reloaded"]["capturedBody"] == "Captured on Android"
|
|
|
|
|
|
def test_initial_owner_picker_discards_an_older_same_repository_response():
|
|
script = f"""
|
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
let repository='o/a'; const pending=[];
|
|
function select(){{const listeners={{}};return{{value:'',dataset:{{}},children:[],selectedOptions:[],
|
|
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},appendChild(option){{this.children.push(option);if(option.value===this.value)this.selectedOptions=[option];}},
|
|
addEventListener:(name,fn)=>listeners[name]=fn}};}}
|
|
const owner=select(),status={{textContent:''}},repo={{get value(){{return repository;}}}};
|
|
const documentRef={{querySelector:id=>id==='#create-issue-assignee'?owner:id==='#create-issue-assignee-status'?status:repo,
|
|
createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
|
|
const capture={{loadOwners:()=>new Promise(resolve=>pending.push(resolve))}};
|
|
const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
|
|
(async()=>{{picker.reset('o/a');const first=picker.load('o/a');repository='o/b';picker.reset('o/b');
|
|
repository='o/a';picker.reset('o/a');const latest=picker.load('o/a');pending[1]([{{login:'new',name:'New'}}]);await latest;
|
|
pending[0]([{{login:'old',name:'Old'}}]);await first;
|
|
process.stdout.write(JSON.stringify(owner.children.map(option=>option.value)));}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
assert output == ["", "__unassigned__", "new"]
|
|
|
|
|
|
def test_initial_owner_picker_exposes_no_owner_and_disables_start():
|
|
script = f"""
|
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
function select(){{return{{value:'',dataset:{{}},children:[],selectedOptions:[],
|
|
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},
|
|
appendChild(option){{this.children.push(option);}},addEventListener:()=>{{}}}};}}
|
|
const owner=select(),status={{textContent:''}},repo={{value:'o/r'}};
|
|
const submit={{disabled:false,textContent:''}},start={{disabled:false,title:''}};
|
|
const documentRef={{querySelector:id=>({{
|
|
'#create-issue-assignee':owner,'#create-issue-assignee-status':status,
|
|
'#create-issue-repository':repo,'#submit-new-issue':submit,'#create-and-start-issue':start,
|
|
}}[id]),createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
|
|
const picker=createIssueCapture.createOwnerPicker({{loadOwners:async()=>[]}},documentRef,()=>{{}});
|
|
picker.reset('o/r',{{unassigned:true}});owner.value='__unassigned__';
|
|
picker.updateActions(true,false,true);
|
|
process.stdout.write(JSON.stringify({{options:owner.children.map(option=>[option.value,option.textContent]),fields:picker.fields(),submit,start}}));
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["options"] == [["", "Me"], ["__unassigned__", "No owner"]]
|
|
assert output["fields"] == {"assignee": "", "assigneeName": "", "unassigned": True}
|
|
assert output["submit"]["textContent"] == "Create unassigned"
|
|
assert output["start"]["disabled"] is True
|
|
assert "No owner" in output["start"]["title"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
|
|
html = await dashboard()
|
|
assert 'id="create-issue-blocker-search" type="search"' in html
|
|
assert 'id="create-issue-blocker-results" role="listbox"' in html
|
|
assert 'id="create-issue-blocker-selected"' in html
|
|
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
|
|
assert "issueCapture.searchBlockers(query)" in html
|
|
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
|
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
|
|
assert "issueCaptureBlockers.length > 0, createAndStart.available()" in html
|
|
assert ".create-issue-blocker-result" in html
|
|
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
|
|
assert "@media(max-width:320px)" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
|
|
html = await dashboard()
|
|
assert 'id="create-issue-assignee"' in html
|
|
assert '<option value="">Me</option>' in html
|
|
assert '<option value="__unassigned__">No owner</option>' in html
|
|
assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html
|
|
assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html
|
|
assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html
|
|
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
|
assert "issueOwnerPicker.updateActions(" in html
|
|
assert '.create-issue-owner select { min-height:44px;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_filing_exposes_repository_issue_types_without_blocking_blank_filing():
|
|
html = await dashboard()
|
|
source = CREATE_ISSUE_SHEET.read_text()
|
|
assert 'id="create-issue-template"' in html
|
|
assert '<option value="">Blank issue</option>' in html
|
|
assert 'id="create-issue-template-status" class="small" aria-live="polite"' in html
|
|
assert 'templatePicker.render(repository, metadata.templates' in source
|
|
assert 'issueCapture.bindTemplatePicker(' in html
|
|
assert 'id="issue-filing-review-template"' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_every_mobile_repository_selection_uses_the_resilient_metadata_loader():
|
|
html = await dashboard()
|
|
source = CREATE_ISSUE_SHEET.read_text()
|
|
|
|
assert "async function loadIssueFilingMetadata(repository" in html
|
|
assert "issueCapture.bindFilingMetadata(" in html
|
|
assert "issueFilingMetadata.load(repository, selected)" in html
|
|
assert "const metadata = await loadFilingMetadata(repository)" in source
|
|
assert "renderLabels(repository, metadata.labels" in source
|
|
assert "renderMilestones(repository, metadata.milestones" in source
|
|
assert "templatePicker.render(repository, metadata.templates" in source
|
|
assert html.count("loadIssueFilingMetadata(") == 5
|
|
assert "loadIssueLabels(repository);\n loadIssueMilestones(repository);" not in html
|
|
assert "loadIssueLabels(event.target.value).then" not in html
|
|
assert "loadIssueLabels(qs('#create-issue-repository').value" not in html
|
|
|
|
|
|
def test_issue_capture_loads_all_filing_metadata_in_one_section_isolated_request():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const calls = [];
|
|
const capture = createIssueCapture({{
|
|
storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
|
fetchJson:url=>{{
|
|
calls.push(url);
|
|
return Promise.resolve({{
|
|
labels:{{available:true,items:[{{id:3,name:'P0'}}]}},
|
|
milestones:{{available:false,items:[],error:'Milestones could not be loaded.'}},
|
|
templates:{{available:true,items:[{{id:'bug',name:'Bug report'}}]}},
|
|
}});
|
|
}},
|
|
}});
|
|
capture.loadFilingMetadata('stackchain/api').then(result=>
|
|
process.stdout.write(JSON.stringify({{calls,result}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output == {
|
|
"calls": ["api/v1/repos/stackchain/api/issue-filing-metadata"],
|
|
"result": {
|
|
"status": "ready",
|
|
"repository": "stackchain/api",
|
|
"labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
|
|
"milestones": {
|
|
"available": False,
|
|
"items": [],
|
|
"error": "Milestones could not be loaded.",
|
|
},
|
|
"templates": {
|
|
"available": True,
|
|
"items": [{"id": "bug", "name": "Bug report"}],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def test_issue_capture_rejects_filing_metadata_from_a_stale_repository_request():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const releases = {{}};
|
|
const capture = createIssueCapture({{
|
|
storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
|
fetchJson:url=>new Promise(resolve=>{{ releases[url] = resolve; }}),
|
|
}});
|
|
const first = capture.loadFilingMetadata('stackchain/old');
|
|
const second = capture.loadFilingMetadata('stackchain/current');
|
|
const payload = {{
|
|
labels:{{available:true,items:[]}}, milestones:{{available:true,items:[]}},
|
|
templates:{{available:true,items:[]}},
|
|
}};
|
|
releases['api/v1/repos/stackchain/current/issue-filing-metadata'](payload);
|
|
Promise.resolve().then(()=>{{
|
|
releases['api/v1/repos/stackchain/old/issue-filing-metadata'](payload);
|
|
return Promise.all([first, second]);
|
|
}}).then(results=>process.stdout.write(JSON.stringify(results)));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == [
|
|
{"status": "stale", "repository": "stackchain/old"},
|
|
{
|
|
"status": "ready", "repository": "stackchain/current",
|
|
"labels": {"available": True, "items": []},
|
|
"milestones": {"available": True, "items": []},
|
|
"templates": {"available": True, "items": []},
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
|
html = await dashboard()
|
|
|
|
assert 'id="edit-issue-content"' in html
|
|
assert 'id="issue-edit-form"' in html
|
|
assert 'id="issue-edit-title" type="text" maxlength="255"' in html
|
|
assert 'id="issue-edit-body" maxlength="10000"' in html
|
|
assert 'id="save-issue-content"' in html
|
|
assert 'id="cancel-issue-content"' in html
|
|
assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
|
|
assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
|
|
assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
|
|
assert 'buildMyWork.replaceIssueContent(' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
|
|
html = await dashboard()
|
|
|
|
assert 'id="find-work"' in html
|
|
assert 'id="find-work-sheet" role="dialog" aria-modal="true"' in html
|
|
assert 'id="find-work-list"' in html
|
|
assert 'id="find-work-status" class="small" aria-live="assertive"' in html
|
|
assert 'id="load-more-available"' in html
|
|
assert 'static/pick-work.js' in html
|
|
assert '.find-work-action { min-height:44px;' in html
|
|
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
|
|
assert '@media(max-width:320px)' in html
|
|
assert 'const retainedItems = findWorkController.items();' in html
|
|
assert 'if (retainedItems.length) renderAvailableIssues(retainedItems);' in html
|
|
assert 'Refreshing available issues…' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requests():
|
|
html = await dashboard()
|
|
|
|
assert 'data-preview-index=' in html
|
|
assert 'aria-expanded="' in html
|
|
assert 'aria-controls="' in html
|
|
assert "const detailId = 'find-work-detail-' + index" in html
|
|
assert 'class="find-work-detail"' in html
|
|
assert "renderMarkdown(item.body || 'No description provided.')" in html
|
|
assert '>Open in Gitea</a>' in html
|
|
assert '.find-work-card a, .find-work-more { min-height:44px;' in html
|
|
assert '.find-work-detail { min-width:0;' in html
|
|
|
|
|
|
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let calls = [];
|
|
let release;
|
|
const capture = createIssueCapture({{
|
|
storage,
|
|
fetchJson: (url, options) => {{
|
|
calls.push({{url, options}});
|
|
return new Promise(resolve => {{ release = () => resolve({{
|
|
id:81, number:17, title:'Capture work', state:'open', repository:'stackchain/api',
|
|
labels:[], assignees:['timmy'], url:'https://forge.example/issues/17'
|
|
}}); }});
|
|
}},
|
|
}});
|
|
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
|
|
capture.saveDraft(draft);
|
|
const first = capture.submit(draft);
|
|
const duplicate = capture.submit(draft);
|
|
const during = capture.loadDraft();
|
|
release();
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls:calls.map(call => ({{url:call.url, method:call.options.method,
|
|
body:JSON.parse(call.options.body)}})), during, after:capture.loadDraft(), results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [{
|
|
"url": "api/v1/repos/stackchain/api/issues",
|
|
"method": "POST",
|
|
"body": {"title": "Capture work", "body": "Context", "label_ids": [3]},
|
|
}]
|
|
assert output["during"] == {
|
|
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
|
|
"labelIds": [3],
|
|
}
|
|
assert output["after"] == {
|
|
"repository": "", "title": "", "body": "", "labelIds": []
|
|
}
|
|
assert output["results"][0]["number"] == 17
|
|
assert output["results"][1]["number"] == 17
|
|
|
|
|
|
def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let calls = 0;
|
|
let release;
|
|
const capture = createIssueCapture({{
|
|
storage,
|
|
fetchJson: url => {{
|
|
calls += 1;
|
|
if (calls === 1) return Promise.reject(new Error('temporary'));
|
|
return new Promise(resolve => {{ release = () => resolve({{
|
|
items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
|
|
}}); }});
|
|
}},
|
|
}});
|
|
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
|
capture.loadRepositoryPage(2).catch(() => {{
|
|
const first = capture.loadRepositoryPage(2);
|
|
const duplicate = capture.loadRepositoryPage(2);
|
|
release();
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, results, draft:capture.loadDraft()
|
|
}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == 2
|
|
assert output["results"] == [
|
|
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
|
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
|
]
|
|
assert output["draft"] == {
|
|
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
|
|
}
|
|
|
|
|
|
def test_issue_capture_repository_search_ignores_late_results_and_preserves_draft():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const pending = {{}};
|
|
const calls = [];
|
|
const capture = createIssueCapture({{
|
|
storage,
|
|
fetchJson: url => {{
|
|
calls.push(url);
|
|
return new Promise(resolve => {{ pending[url] = resolve; }});
|
|
}},
|
|
}});
|
|
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
|
const first = capture.searchRepositories('mob');
|
|
const second = capture.searchRepositories('mobile');
|
|
pending['api/v1/repositories/search?q=mobile&limit=20']({{items:[{{full_name:'stackchain/mobile'}}]}});
|
|
second.then(secondResult => {{
|
|
pending['api/v1/repositories/search?q=mob&limit=20']({{items:[{{full_name:'stackchain/obsolete'}}]}});
|
|
first.then(firstResult => process.stdout.write(JSON.stringify({{
|
|
calls, firstResult, secondResult, draft:capture.loadDraft()
|
|
}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output == {
|
|
"calls": [
|
|
"api/v1/repositories/search?q=mob&limit=20",
|
|
"api/v1/repositories/search?q=mobile&limit=20",
|
|
],
|
|
"firstResult": {"status": "stale", "items": []},
|
|
"secondResult": {"status": "ready", "items": [{"full_name": "stackchain/mobile"}]},
|
|
"draft": {
|
|
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3],
|
|
},
|
|
}
|
|
|
|
|
|
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const calls = [];
|
|
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
|
|
const first = createIssueCapture({{
|
|
storage,
|
|
createOperationId: () => 'operation-177',
|
|
fetchJson: (_url, options) => {{ calls.push(options.headers['Idempotency-Key']); return Promise.reject(new Error('timeout')); }},
|
|
}});
|
|
first.submit(draft).catch(() => {{
|
|
const restored = createIssueCapture({{
|
|
storage,
|
|
createOperationId: () => 'must-not-replace-operation-177',
|
|
fetchJson: (_url, options) => {{
|
|
calls.push(options.headers['Idempotency-Key']);
|
|
return Promise.resolve({{number:17, title:'Capture work'}});
|
|
}},
|
|
}});
|
|
const before = restored.loadDraft();
|
|
restored.submit(before).then(issue => process.stdout.write(JSON.stringify({{
|
|
calls, before, after:restored.loadDraft(), number:issue.number
|
|
}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == ["operation-177", "operation-177"]
|
|
assert output["before"] == {
|
|
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
|
|
"labelIds": [3],
|
|
}
|
|
assert output["after"] == {
|
|
"repository": "", "title": "", "body": "", "labelIds": []
|
|
}
|
|
assert output["number"] == 17
|
|
|
|
|
|
def test_issue_capture_normalizes_shared_mobile_content_without_repeating_source_url():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const normalized = createIssueCapture.normalizeSharedContent({{
|
|
title: ' Production alert ',
|
|
text: 'Latency crossed the threshold https://status.example/incidents/42',
|
|
url: 'https://status.example/incidents/42',
|
|
}});
|
|
process.stdout.write(JSON.stringify(normalized));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"title": "Production alert",
|
|
"body": "Latency crossed the threshold https://status.example/incidents/42",
|
|
}
|
|
|
|
|
|
def test_issue_capture_suggests_a_title_when_mobile_share_only_contains_text():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
process.stdout.write(JSON.stringify(createIssueCapture.normalizeSharedContent({{
|
|
text: 'Investigate checkout latency before the release window opens. More diagnostic context follows.'
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"title": "Investigate checkout latency before the release window opens.",
|
|
"body": "Investigate checkout latency before the release window opens. More diagnostic context follows.",
|
|
}
|
|
|
|
|
|
def test_issue_capture_keeps_existing_draft_until_shared_content_is_accepted():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
|
capture.saveDraft({{
|
|
repository:'stackchain/api', title:'Existing draft', body:'Keep me', labelIds:[3], milestoneId:9,
|
|
}});
|
|
const staged = capture.stageSharedContent({{title:'Shared alert', text:'Investigate', url:'https://status.example/42'}});
|
|
const before = capture.loadDraft();
|
|
capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
|
const pendingAfterReload = capture.pendingSharedContent();
|
|
const accepted = capture.acceptSharedContent();
|
|
process.stdout.write(JSON.stringify({{staged, before, pendingAfterReload, accepted, pending:capture.pendingSharedContent()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"staged": {"status": "conflict"},
|
|
"before": {
|
|
"repository": "stackchain/api", "title": "Existing draft", "body": "Keep me",
|
|
"labelIds": [3], "milestoneId": 9,
|
|
},
|
|
"pendingAfterReload": {
|
|
"title": "Shared alert", "body": "Investigate\n\nhttps://status.example/42",
|
|
},
|
|
"accepted": {
|
|
"repository": "stackchain/api", "title": "Shared alert",
|
|
"body": "Investigate\n\nhttps://status.example/42", "labelIds": [3], "milestoneId": 9,
|
|
},
|
|
"pending": None,
|
|
}
|
|
|
|
|
|
def test_issue_capture_can_resume_existing_draft_and_discard_staged_share():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:key=>values.get(key)||null, setItem:(key,value)=>values.set(key,value), removeItem:key=>values.delete(key)}};
|
|
const capture = createIssueCapture({{storage, fetchJson:()=>Promise.resolve({{}})}});
|
|
capture.saveDraft({{repository:'stackchain/api', title:'Existing', body:'Keep', labelIds:[]}});
|
|
capture.stageSharedContent({{title:'Incoming', text:'Replace'}});
|
|
capture.discardSharedContent();
|
|
process.stdout.write(JSON.stringify({{draft:capture.loadDraft(), pending:capture.pendingSharedContent()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"draft": {"repository": "stackchain/api", "title": "Existing", "body": "Keep", "labelIds": []},
|
|
"pending": None,
|
|
}
|
|
|
|
|
|
def test_issue_capture_loads_repository_labels_with_priorities_first():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const calls = [];
|
|
const capture = createIssueCapture({{
|
|
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
|
fetchJson: url => {{
|
|
calls.push(url);
|
|
return Promise.resolve([
|
|
{{id:8,name:'frontend',color:'1d76db'}},
|
|
{{id:3,name:'P0',color:'d73a4a'}},
|
|
{{id:5,name:'critical',color:'b60205'}}
|
|
]);
|
|
}},
|
|
}});
|
|
capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.stringify({{calls,labels}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == ["api/v1/repos/stackchain/api/labels"]
|
|
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
|
|
|
|
|
|
def test_issue_capture_persists_and_submits_milestone_and_due_date():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const calls = [];
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const capture = createIssueCapture({{
|
|
storage,
|
|
createOperationId: () => 'planned-operation',
|
|
fetchJson: (url, options) => {{
|
|
calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}});
|
|
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]);
|
|
return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}});
|
|
}},
|
|
}});
|
|
const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}};
|
|
capture.saveDraft(draft);
|
|
Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results =>
|
|
process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [
|
|
{"url": "api/v1/repos/stackchain/api/milestones", "body": None},
|
|
{
|
|
"url": "api/v1/repos/stackchain/api/issues",
|
|
"body": {
|
|
"title": "Ship plan", "body": "", "label_ids": [],
|
|
"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z",
|
|
},
|
|
},
|
|
]
|
|
assert output["milestones"] == [{"id": 9, "title": "August RC"}]
|
|
assert output["stored"]["number"] == 221
|
|
|
|
|
|
def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const calls = [];
|
|
const capture = createIssueCapture({{
|
|
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
|
fetchJson: url => {{
|
|
calls.push(url);
|
|
return Promise.resolve({{items:[
|
|
{{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}},
|
|
{{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}},
|
|
{{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}},
|
|
{{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}},
|
|
{{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}},
|
|
{{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}},
|
|
{{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}},
|
|
]}});
|
|
}},
|
|
}});
|
|
capture.findDuplicates({{repository:'stackchain/api', title:' Investigate checkout latency '}}).then(state =>
|
|
process.stdout.write(JSON.stringify({{calls, state}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [
|
|
"api/v1/search?q=Investigate%20checkout%20latency&limit=10"
|
|
]
|
|
assert output["state"]["status"] == "ready"
|
|
assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12]
|
|
|
|
|
|
def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const pending = [];
|
|
const capture = createIssueCapture({{
|
|
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
|
fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})),
|
|
}});
|
|
const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}};
|
|
const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}};
|
|
const first = capture.findDuplicates(firstDraft);
|
|
const latest = capture.findDuplicates(latestDraft);
|
|
pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}});
|
|
latest.then(latestState => {{
|
|
const blocked = capture.needsDuplicateAcknowledgement(latestDraft);
|
|
capture.acknowledgeDuplicates(latestDraft);
|
|
const allowed = capture.needsDuplicateAcknowledgement(latestDraft);
|
|
const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}};
|
|
const changedSearch = capture.findDuplicates(changedDraft);
|
|
pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}});
|
|
changedSearch.then(() => {{
|
|
const changed = capture.needsDuplicateAcknowledgement(changedDraft);
|
|
pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}});
|
|
first.then(staleState => {{
|
|
const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}});
|
|
pending[3].reject(new Error('offline'));
|
|
failure.then(failedState => process.stdout.write(JSON.stringify({{
|
|
latestState, staleState, blocked, allowed, changed, failedState,
|
|
failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}),
|
|
}})));
|
|
}});
|
|
}});
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["latestState"]["status"] == "ready"
|
|
assert output["staleState"]["status"] == "stale"
|
|
assert output["blocked"] is True
|
|
assert output["allowed"] is False
|
|
assert output["changed"] is True
|
|
assert output["failedState"]["status"] == "failed"
|
|
assert output["failureBlocks"] is False
|
|
|
|
|
|
def test_issue_capture_keeps_partial_duplicate_search_advisory():
|
|
script = f"""
|
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
|
const draft = {{repository:'stackchain/api', title:'Checkout latency'}};
|
|
const capture = createIssueCapture({{
|
|
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
|
fetchJson: () => Promise.resolve({{partial:true, items:[
|
|
{{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}},
|
|
]}}),
|
|
}});
|
|
capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{
|
|
state, blocked:capture.needsDuplicateAcknowledgement(draft),
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["state"]["partial"] is True
|
|
assert [item["number"] for item in output["state"]["candidates"]] == [24]
|
|
assert output["blocked"] is False
|
|
|
|
|
|
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/api#7", "stackchain/web#8"]
|
|
assert output["updates"][0]["kind"] == "issue"
|
|
assert output["updates"][0]["update_reason"] == "Assigned to you"
|
|
assert output["updates"][0]["url"].endswith("#issuecomment-9")
|
|
assert output["updates"][1]["kind"] == "update"
|
|
assert output["updates"][1]["update_reason"] == ""
|
|
assert output["counts"] == {
|
|
"all": 2, "attention": 2, "filed": 0, "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_selection_tracks_a_bounded_subset_and_clears_on_cancel():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const states = [];
|
|
const selection = buildMyWork.createNotificationSelection({{
|
|
limit: 2,
|
|
onChange: state => states.push(state),
|
|
}});
|
|
selection.start();
|
|
const first = selection.select(42);
|
|
const duplicate = selection.select(42);
|
|
selection.toggle(42);
|
|
selection.select(43);
|
|
selection.select(44);
|
|
const second = selection.select(45);
|
|
selection.cancel();
|
|
process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["first"] == "selected"
|
|
assert output["duplicate"] == "already-selected"
|
|
assert output["second"] == "limit"
|
|
assert output["states"] == [
|
|
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
|
{"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
|
|
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
|
{"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
|
|
{"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
|
|
{"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
|
]
|
|
assert output["snapshot"] == output["states"][-1]
|
|
|
|
|
|
def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const states = [];
|
|
const selection = buildMyWork.createNotificationSelection({{
|
|
onChange: state => states.push(state),
|
|
}});
|
|
selection.start();
|
|
selection.select(42);
|
|
selection.select(43);
|
|
selection.select(44);
|
|
const retained = selection.retain([44, 44, 99]);
|
|
process.stdout.write(JSON.stringify({{retained, states}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["retained"] == {
|
|
"active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
|
|
}
|
|
assert output["states"][-1] == output["retained"]
|
|
|
|
|
|
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_consumes_one_bounded_prefetch_without_duplicate_load():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}}));
|
|
const loaded = [], details = [], statuses = [];
|
|
const releases = {{}};
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: id => {{
|
|
loaded.push(id);
|
|
if (id === 1) return Promise.resolve({{id}});
|
|
return new Promise(resolve => {{ releases[id] = resolve; }});
|
|
}},
|
|
markRead: async () => {{}},
|
|
onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}},
|
|
onStatus: status => statuses.push(status), onClose: () => {{}},
|
|
}});
|
|
(async () => {{
|
|
await reader.open(items[0]);
|
|
const first = reader.prefetch(items[1]);
|
|
const duplicate = reader.prefetch(items[1]);
|
|
const rejected = reader.prefetch(items[2]);
|
|
const advancing = reader.open(items[1]);
|
|
await Promise.resolve();
|
|
releases[2]({{id:2}});
|
|
await advancing;
|
|
process.stdout.write(JSON.stringify({{
|
|
loaded, details, same:first === duplicate, rejected, statuses,
|
|
}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output == {
|
|
"loaded": [1, 2],
|
|
"details": [1, 2],
|
|
"same": True,
|
|
"rejected": False,
|
|
"statuses": ["Loading update…", "Update ready.", "Update ready."],
|
|
}
|
|
|
|
|
|
def test_notification_reader_never_consumes_prefetch_from_another_account_scope():
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
let scope = 'timmy';
|
|
const loaded = [];
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }},
|
|
getScope: () => scope,
|
|
markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}},
|
|
onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }},
|
|
onClose: () => {{}},
|
|
}});
|
|
(async () => {{
|
|
await reader.open({{notification_id:1}});
|
|
await reader.prefetch({{notification_id:2}});
|
|
scope = 'alexander';
|
|
const opened = await reader.open({{notification_id:2}});
|
|
process.stdout.write(JSON.stringify({{loaded, opened}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True}
|
|
|
|
|
|
def test_notification_reader_acknowledges_once_and_opens_next_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}},
|
|
];
|
|
let release;
|
|
const calls = [];
|
|
const events = [];
|
|
const reader = buildMyWork.createNotificationReader({{
|
|
load: async id => ({{id}}), markRead: async () => {{}},
|
|
acknowledge: id => new Promise(resolve => {{ calls.push(id); release = resolve; }}),
|
|
onOpen: item => events.push(['open', item.notification_id]), onDetail: () => {{}},
|
|
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
|
|
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
|
}});
|
|
(async () => {{
|
|
await reader.open(items[0]);
|
|
events.length = 0;
|
|
const first = reader.acknowledgeAndNext(items);
|
|
const duplicate = reader.acknowledgeAndNext(items);
|
|
await Promise.resolve();
|
|
release({{reaction:'created', status:'read'}});
|
|
const results = await Promise.all([first, duplicate]);
|
|
process.stdout.write(JSON.stringify({{calls, events, results}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["calls"] == [42]
|
|
assert output["events"] == [
|
|
["status", "Adding reaction and marking read…"],
|
|
["items", [43]],
|
|
["open", 43],
|
|
["status", "Loading update…"],
|
|
["status", "Update ready."],
|
|
]
|
|
assert output["results"][0]["next"]["notification_id"] == 43
|
|
assert output["results"][1] is False
|
|
|
|
|
|
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
|
|
script = f"""
|
|
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
|
|
const states = [];
|
|
const statuses = [];
|
|
const calls = [];
|
|
let attempts = 0;
|
|
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
|
|
const undo = createNotificationUndo({{
|
|
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
|
|
onItems: items => states.push(items.map(value => value.notification_id)),
|
|
onStatus: status => statuses.push(status),
|
|
}});
|
|
(async () => {{
|
|
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
|
|
const first = undo.run();
|
|
const duplicate = undo.run();
|
|
const firstResults = await Promise.all([first, duplicate]);
|
|
const retry = await undo.run();
|
|
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["calls"] == [42, 42]
|
|
assert output["states"] == [[42, 43]]
|
|
assert output["firstResults"] == [False, False]
|
|
assert output["retry"] is True
|
|
assert output["statuses"] == [
|
|
"repo#7 marked read. Undo?",
|
|
"Restoring repo#7…",
|
|
"Could not restore repo#7. Retry Undo.",
|
|
"Restoring repo#7…",
|
|
"repo#7 is unread again.",
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
|
|
html = await dashboard()
|
|
|
|
assert 'id="notification-undo"' in html
|
|
assert 'id="undo-notification" type="button"' in html
|
|
assert "requestNotificationUnread" in html
|
|
assert "createDashboardNotificationUndo({" in html
|
|
assert ".notification-undo { position:fixed;" in html
|
|
assert "env(safe-area-inset-bottom)" in html
|
|
assert ".notification-undo button { min-height:44px;" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
|
|
html = await dashboard()
|
|
|
|
assert (
|
|
'id="acknowledge-update-next" type="button" hidden '
|
|
'aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>'
|
|
) in html
|
|
assert "qs('#acknowledge-update-next').hidden = true;" in html
|
|
assert "qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;" in html
|
|
assert "async function acknowledgeNotification(notificationId)" in html
|
|
assert "'/acknowledge', { method: 'POST'" in html
|
|
assert "acknowledge: acknowledgeNotification" in html
|
|
assert "notificationReader.acknowledgeAndNext(lastMyWork)" in html
|
|
assert "qs('#acknowledge-update-next').disabled = offline;" in html
|
|
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
|
|
html = await dashboard()
|
|
|
|
assert 'id="mute-update-next" type="button" hidden' in html
|
|
assert ">Mute future updates & next</button>" in html
|
|
assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
|
|
assert "async function muteNotification(notificationId)" in html
|
|
assert "'/mute', { method: 'POST'" in html
|
|
assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
|
assert "Future updates are muted; current item is still unread" in html
|
|
assert "qs('#mute-update-next').disabled = offline;" in html
|
|
assert ".update-sheet-actions #mute-update-next" in html
|
|
|
|
|
|
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_pages_conversation_single_flight_and_ignores_stale_update():
|
|
script = f"""
|
|
const build = require({json.dumps(str(MY_WORK))});
|
|
const createPager = require({json.dumps(str(CONVERSATION))});
|
|
const details = {{
|
|
42: {{id:42, conversation:{{comments:[{{id:41,created_at:'2026-08-07T12:41:00Z'}}],page:3,older_page:2,total:47}}}},
|
|
43: {{id:43, conversation:{{comments:[{{id:90,created_at:'2026-08-07T13:00:00Z'}}],page:1,older_page:null,total:1}}}},
|
|
}};
|
|
let release;
|
|
const requested = [];
|
|
const states = [];
|
|
const reader = build.createNotificationReader({{
|
|
load: async id => details[id], createPager,
|
|
loadConversation: (id, page) => {{
|
|
requested.push([id, page]);
|
|
return new Promise(resolve => {{ release = () => resolve({{comments:[{{id:21,created_at:'2026-08-07T12:21:00Z'}}],page:2,older_page:1,total:47}}); }});
|
|
}},
|
|
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
|
|
onConversation: state => states.push(state.comments.map(item => item.id)),
|
|
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
|
}});
|
|
const firstItem = {{notification_id:42}};
|
|
const secondItem = {{notification_id:43}};
|
|
reader.open(firstItem).then(async () => {{
|
|
const first = reader.loadOlder();
|
|
const duplicate = reader.loadOlder();
|
|
await reader.open(secondItem);
|
|
release();
|
|
await Promise.all([first, duplicate]);
|
|
process.stdout.write(JSON.stringify({{requested, states}}));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["requested"] == [[42, 2]]
|
|
assert output["states"] == [[41], [90]]
|
|
|
|
|
|
def test_notification_reader_renders_core_before_conversation_and_retries_failure_in_place():
|
|
script = f"""
|
|
const build = require({json.dumps(str(MY_WORK))});
|
|
const createPager = require({json.dumps(str(CONVERSATION))});
|
|
const events = [];
|
|
let attempts = 0;
|
|
let release;
|
|
const reader = build.createNotificationReader({{
|
|
load: async id => ({{id, title:'Core ready', conversation_available:true}}),
|
|
createPager,
|
|
loadConversation: () => {{
|
|
attempts += 1;
|
|
if (attempts === 1) return Promise.reject(new Error('comments offline'));
|
|
return new Promise(resolve => {{ release = () => resolve({{
|
|
comments:[{{id:47, body:'Newest'}}], page:3, older_page:2, total:47,
|
|
}}); }});
|
|
}},
|
|
markRead: async () => {{}}, onOpen: () => events.push('open'),
|
|
onDetail: detail => events.push(['detail', detail.title]),
|
|
onConversation: state => events.push(['conversation', state.comments.map(item => item.id)]),
|
|
onConversationStatus: status => events.push(['conversation-status', status]),
|
|
onItems: () => {{}}, onStatus: status => events.push(['status', status]), onClose: () => {{}},
|
|
}});
|
|
(async () => {{
|
|
const opened = await reader.open({{notification_id:42}});
|
|
const retrying = reader.retryConversation();
|
|
await Promise.resolve();
|
|
const beforeRelease = events.slice();
|
|
release();
|
|
const retried = await retrying;
|
|
process.stdout.write(JSON.stringify({{opened, retried, attempts, beforeRelease, events}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["opened"] is True
|
|
assert output["retried"] is True
|
|
assert output["attempts"] == 2
|
|
assert ["detail", "Core ready"] in output["beforeRelease"]
|
|
assert ["status", "Update ready."] in output["beforeRelease"]
|
|
assert ["conversation-status", "Conversation temporarily unavailable. Retry."] in output["beforeRelease"]
|
|
assert ["conversation", [47]] not in output["beforeRelease"]
|
|
assert output["events"][-2:] == [
|
|
["conversation", [47]],
|
|
["conversation-status", "1 of 47 messages loaded."],
|
|
]
|
|
|
|
|
|
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
|
|
script = f"""
|
|
const build = require({json.dumps(str(MY_WORK))});
|
|
const createPager = require({json.dumps(str(CONVERSATION))});
|
|
const item = {{kind:'update', notification_id:42, has_update:true}};
|
|
const saved = {{
|
|
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
|
|
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
|
|
}};
|
|
let detailLoads = 0;
|
|
let conversationLoads = 0;
|
|
let markReads = 0;
|
|
const details = [];
|
|
const conversations = [];
|
|
const reader = build.createNotificationReader({{
|
|
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
|
|
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
|
|
markRead: async () => {{ markReads += 1; }}, createPager,
|
|
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
|
|
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
|
|
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
|
}});
|
|
(async () => {{
|
|
const opened = await reader.open(item, saved);
|
|
const older = await reader.loadOlder();
|
|
const marked = await reader.markReadAndNext([item]);
|
|
process.stdout.write(JSON.stringify({{
|
|
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
|
|
}}));
|
|
}})();
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"opened": True,
|
|
"older": False,
|
|
"marked": False,
|
|
"detailLoads": 0,
|
|
"conversationLoads": 0,
|
|
"markReads": 0,
|
|
"details": ["Saved update"],
|
|
"conversations": [[41]],
|
|
}
|
|
|
|
|
|
def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
|
|
script = f"""
|
|
const build = require({json.dumps(str(MY_WORK))});
|
|
const items = [
|
|
{{kind:'update', notification_id:42, has_update:true, title:'First'}},
|
|
{{kind:'update', notification_id:43, has_update:true, title:'Second'}},
|
|
];
|
|
const saved = new Map([
|
|
[42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
|
|
[43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
|
|
]);
|
|
const events = [];
|
|
const reader = build.createNotificationReader({{
|
|
load: async () => {{ throw new Error('network must not run'); }},
|
|
markRead: async () => {{ throw new Error('network must not run'); }},
|
|
queueRead: async id => events.push(['queued', id]),
|
|
loadSaved: item => saved.get(item.notification_id),
|
|
onOpen: item => events.push(['open', item.notification_id]),
|
|
onDetail: detail => events.push(['detail', detail.id]),
|
|
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
|
|
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
|
}});
|
|
(async () => {{
|
|
await reader.open(items[0], saved.get(42));
|
|
events.length = 0;
|
|
const result = await reader.markReadAndNext(items);
|
|
process.stdout.write(JSON.stringify({{result, events}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["result"]["next"]["notification_id"] == 43
|
|
assert output["events"] == [
|
|
["status", "Queueing update read…"],
|
|
["queued", 42],
|
|
["items", [43]],
|
|
["open", 43],
|
|
["status", "Loading update…"],
|
|
["detail", 43],
|
|
["status", "Update ready."],
|
|
]
|
|
|
|
|
|
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
|
|
script = f"""
|
|
const build = require({json.dumps(str(MY_WORK))});
|
|
const createPager = require({json.dumps(str(CONVERSATION))});
|
|
const states = [];
|
|
const reader = build.createNotificationReader({{
|
|
load: async () => ({{conversation:{{comments:[{{id:41}}],page:1,older_page:null,total:1}}}}),
|
|
loadConversation: async () => ({{}}), createPager,
|
|
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
|
|
onConversation: state => states.push(state.comments.map(item => item.id)),
|
|
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
|
}});
|
|
reader.open({{notification_id:42}}).then(() => {{
|
|
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
|
|
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
|
|
process.stdout.write(JSON.stringify(states));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == [[41], [41, 91], [41, 91]]
|
|
|
|
|
|
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": [], "item": {
|
|
"kind": "update", "notification_id": 42, "has_update": True,
|
|
}, "next": None}
|
|
assert output["events"] == [
|
|
["status", "Marking update read…"],
|
|
["items", []],
|
|
["close"],
|
|
["status", "Inbox cleared."],
|
|
]
|
|
|
|
|
|
def test_conversation_pager_prepends_older_pages_deduplicates_and_appends_once():
|
|
script = f"""
|
|
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
|
let calls = 0;
|
|
const pager = createConversationPager({{
|
|
loadPage: async page => {{ calls += 1; return {{
|
|
comments:[{{id:20,body:'duplicate'}},{{id:1,body:'oldest'}}],
|
|
page, older_page:null, total:3
|
|
}}; }}
|
|
}});
|
|
pager.reset({{comments:[{{id:20,body:'middle'}},{{id:21,body:'newest'}}],page:2,older_page:1,total:3}});
|
|
const first = pager.loadOlder();
|
|
const duplicate = pager.loadOlder();
|
|
Promise.all([first, duplicate]).then(() => {{
|
|
pager.append({{id:22,body:'posted'}});
|
|
pager.append({{id:22,body:'posted'}});
|
|
process.stdout.write(JSON.stringify({{calls,same:first===duplicate,state:pager.snapshot()}}));
|
|
}});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["calls"] == 1
|
|
assert output["same"] is True
|
|
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 20, 21, 22]
|
|
assert output["state"]["older_page"] is None
|
|
assert output["state"]["total"] == 4
|
|
|
|
|
|
def test_owned_comment_actions_edit_delete_all_conversation_routes_and_preserve_failed_edit():
|
|
script = f"""
|
|
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
|
const createCommentActions = require({json.dumps(str(COMMENT_ACTIONS))});
|
|
const requests = [];
|
|
let shouldFail = false;
|
|
const controller = createCommentActions({{
|
|
getLogin: () => 'timmy',
|
|
confirmDelete: () => true,
|
|
fetchJson: async (url, options) => {{
|
|
requests.push([url, options.method, options.body || null]);
|
|
if (shouldFail) throw new Error('offline');
|
|
if (options.method === 'DELETE') return {{id:42,deleted:true}};
|
|
return {{id:42,author:'timmy',body:JSON.parse(options.body).body,created_at:'2026-08-11T10:00:00Z'}};
|
|
}},
|
|
}});
|
|
const cancelController = createCommentActions({{
|
|
getLogin: () => 'timmy', confirmDelete: () => false,
|
|
fetchJson: async () => {{ throw new Error('delete should not run'); }},
|
|
}});
|
|
const contexts = [
|
|
{{kind:'issue',item:{{repository:'stackchain/api',number:17}}}},
|
|
{{kind:'pull',item:{{repository:'stackchain/api',number:17}}}},
|
|
{{kind:'update',item:{{notification_id:91}}}},
|
|
];
|
|
(async () => {{
|
|
const results = [];
|
|
for (const context of contexts) {{
|
|
const pager = createConversationPager({{loadPage:async()=>({{}})}});
|
|
pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}},{{id:43,author:'alex',body:'Other'}}],total:2}});
|
|
results.push(controller.isOwned(pager.snapshot().comments[0]));
|
|
results.push(controller.isOwned(pager.snapshot().comments[1]));
|
|
await controller.edit(context, pager, 42, 'Corrected');
|
|
results.push(pager.snapshot().comments[0].body);
|
|
await controller.remove(context, pager, 42);
|
|
results.push([pager.snapshot().comments.map(item=>item.id), pager.snapshot().total]);
|
|
}}
|
|
const pager = createConversationPager({{loadPage:async()=>({{}})}});
|
|
pager.reset({{comments:[{{id:42,author:'timmy',body:'Original'}}],total:1}});
|
|
shouldFail = true;
|
|
let failure = '';
|
|
try {{ await controller.edit(contexts[0], pager, 42, 'Unsaved correction'); }} catch (error) {{ failure=error.message; }}
|
|
const cancelled = await cancelController.remove(contexts[0], pager, 42);
|
|
process.stdout.write(JSON.stringify({{requests,results,failure,cancelled,state:pager.snapshot()}}));
|
|
}})();
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
assert [request[:2] for request in output["requests"][:6]] == [
|
|
["api/v1/repos/stackchain/api/issues/17/comments/42", "PATCH"],
|
|
["api/v1/repos/stackchain/api/issues/17/comments/42", "DELETE"],
|
|
["api/v1/repos/stackchain/api/pulls/17/comments/42", "PATCH"],
|
|
["api/v1/repos/stackchain/api/pulls/17/comments/42", "DELETE"],
|
|
["api/v1/notifications/91/comments/42", "PATCH"],
|
|
["api/v1/notifications/91/comments/42", "DELETE"],
|
|
]
|
|
assert output["results"] == [True, False, "Corrected", [[43], 1]] * 3
|
|
assert output["failure"] == "offline"
|
|
assert output["cancelled"] is None
|
|
assert output["state"]["comments"][0]["body"] == "Original"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_owned_comment_actions_render_inline_for_mobile_in_all_conversations():
|
|
html = await dashboard()
|
|
dashboard_javascript = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
|
action_javascript = COMMENT_ACTIONS.read_text()
|
|
javascript = dashboard_javascript + action_javascript
|
|
css = (Path(__file__).parents[1] / "frontend" / "dashboard.css").read_text()
|
|
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
|
|
|
assert '<script src="static/comment-actions.js"></script>' in html
|
|
assert html.index('static/comment-actions.js') < html.index('static/dashboard.js')
|
|
assert "BASE + 'static/comment-actions.js'" in service_worker
|
|
assert 'data-comment-action="edit"' in javascript
|
|
assert 'data-comment-action="delete"' in javascript
|
|
assert 'class="comment-edit-textarea"' in javascript
|
|
assert "wireCommentActions('#issue-comments')" in javascript
|
|
assert "wireCommentActions('#pull-comments')" in javascript
|
|
assert "wireCommentActions('#update-comments')" in javascript
|
|
assert ".comment-owned-actions button" in css and "min-height:44px" in css
|
|
assert ".comment-edit-textarea" in css and "max-width:100%" in css
|
|
assert ".issue-comment" in css and "overflow-wrap:anywhere" in css
|
|
|
|
|
|
def test_notification_reader_exposes_current_comment_pager_only_while_open():
|
|
script = f"""
|
|
const {{createNotificationReader}} = require({json.dumps(str(MY_WORK))});
|
|
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
|
const reader = createNotificationReader({{
|
|
load: async () => ({{conversation:{{comments:[{{id:42,body:'Before'}}],total:1}}}}),
|
|
markRead: async()=>{{}}, onOpen:()=>{{}}, onDetail:()=>{{}}, onItems:()=>{{}}, onStatus:()=>{{}}, onClose:()=>{{}},
|
|
loadConversation: async()=>({{}}), createPager:createConversationPager,
|
|
}});
|
|
(async()=>{{
|
|
const before = reader.commentPager();
|
|
await reader.open({{notification_id:91}});
|
|
const pager = reader.commentPager();
|
|
pager.replace({{id:42,body:'After'}});
|
|
process.stdout.write(JSON.stringify({{before:before===null,body:pager.snapshot().comments[0].body}}));
|
|
}})();
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
assert json.loads(result.stdout) == {"before": True, "body": "After"}
|
|
|
|
|
|
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
|
|
script = f"""
|
|
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
|
const pager = createConversationPager({{loadPage: async () => {{ throw new Error('offline'); }}}});
|
|
pager.reset({{comments:[{{id:21,body:'newest'}}],page:2,older_page:1,total:21}});
|
|
pager.loadOlder().catch(error => process.stdout.write(JSON.stringify({{
|
|
error:error.message, state:pager.snapshot()
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["error"] == "offline"
|
|
assert output["state"]["comments"] == [{"id": 21, "body": "newest"}]
|
|
assert output["state"]["older_page"] == 1
|
|
|
|
|
|
def test_issue_sheet_conversation_loads_older_history_through_assigned_boundary():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
|
const calls = [];
|
|
const controller = createIssueSheet({{
|
|
createConversationPager,
|
|
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
|
|
}});
|
|
const pager = controller.conversation(
|
|
{{repository:'stackchain/api',number:7}},
|
|
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
|
|
);
|
|
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["calls"] == ["api/v1/repos/stackchain/api/issues/7/comments?page=1&limit=20"]
|
|
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
|
|
|
|
|
|
def test_pull_sheet_conversation_loads_older_history_through_assigned_boundary():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
|
const calls = [];
|
|
const controller = createPullSheet({{
|
|
createConversationPager,
|
|
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
|
|
}});
|
|
const pager = controller.conversation(
|
|
{{repository:'stackchain/api',number:7}},
|
|
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
|
|
);
|
|
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["calls"] == ["api/v1/repos/stackchain/api/pulls/7/comments?page=1&limit=20"]
|
|
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
|
|
|
|
|
|
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
let request;
|
|
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
|
|
request = {{url, accept:options.headers.Accept}};
|
|
return {{title:'Fix mobile flow'}};
|
|
}} }});
|
|
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/issues/7/detail",
|
|
"accept": "application/json",
|
|
},
|
|
"title": "Fix mobile flow",
|
|
}
|
|
|
|
|
|
def test_issue_sheet_loads_labels_through_assigned_issue_boundary():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
let request;
|
|
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
|
|
request = {{url, accept:options.headers.Accept}};
|
|
return [{{id:3, name:'P0'}}];
|
|
}} }});
|
|
controller.loadLabels({{repository:'stackchain/api', number:7}}).then(labels =>
|
|
process.stdout.write(JSON.stringify({{request, labels}}))
|
|
);
|
|
"""
|
|
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/issues/7/labels",
|
|
"accept": "application/json",
|
|
},
|
|
"labels": [{"id": 3, "name": "P0"}],
|
|
}
|
|
|
|
|
|
def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
let calls = 0;
|
|
let release;
|
|
const controller = createIssueSheet({{
|
|
storage,
|
|
fetchJson: (url, options) => {{
|
|
calls += 1;
|
|
return new Promise(resolve => {{ release = () => resolve({{id:82, body:'Ready'}}); }});
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
controller.saveDraft(item, 'Ready');
|
|
const first = controller.comment(item, 'Ready');
|
|
const duplicate = controller.comment(item, 'Ready');
|
|
const during = controller.loadDraft(item);
|
|
release();
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, during, after:controller.loadDraft(item), 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["during"] == "Ready"
|
|
assert output["after"] == ""
|
|
assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}]
|
|
|
|
|
|
def test_issue_sheet_label_save_is_single_flight_and_keeps_selection_until_confirmed():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.get(key) || null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const calls = [];
|
|
let release;
|
|
const controller = createIssueSheet({{
|
|
storage,
|
|
fetchJson: (url, options={{}}) => {{
|
|
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
|
|
return new Promise(resolve => {{ release = () => resolve({{number:7, labels:['P0']}}); }});
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const first = controller.updateLabels(item, [3]);
|
|
const duplicate = controller.updateLabels(item, [3]);
|
|
const during = controller.loadLabelDraft(item);
|
|
release();
|
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, during, after:controller.loadLabelDraft(item), results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [{
|
|
"url": "api/v1/repos/stackchain/api/issues/7/labels",
|
|
"method": "PATCH",
|
|
"body": {"label_ids": [3]},
|
|
}]
|
|
assert output["during"] == [3]
|
|
assert output["after"] == []
|
|
assert output["results"] == [
|
|
{"number": 7, "labels": ["P0"]},
|
|
{"number": 7, "labels": ["P0"]},
|
|
]
|
|
|
|
|
|
def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
let calls = 0;
|
|
let release;
|
|
const controller = createIssueSheet({{
|
|
fetchJson: (url, options) => {{
|
|
calls += 1;
|
|
return new Promise(resolve => {{ release = () => resolve({{number:7, state:'closed'}}); }});
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const first = controller.close(item);
|
|
const duplicate = controller.close(item);
|
|
release();
|
|
Promise.all([first, duplicate]).then(results =>
|
|
process.stdout.write(JSON.stringify({{calls, results}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"calls": 1,
|
|
"results": [
|
|
{"number": 7, "state": "closed"},
|
|
{"number": 7, "state": "closed"},
|
|
],
|
|
}
|
|
|
|
|
|
def test_pull_sheet_preserves_comment_draft_and_single_flights_mutations():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
|
const calls = [];
|
|
let releaseComment;
|
|
const controller = createPullSheet({{
|
|
storage,
|
|
fetchJson: (url, options={{}}) => {{
|
|
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
|
|
if (url.endsWith('/comments')) return new Promise(resolve => {{ releaseComment = () => resolve({{id:91, body:'Ship it'}}); }});
|
|
return Promise.resolve({{number:7, merged:true, state:'closed'}});
|
|
}},
|
|
}});
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
controller.saveDraft(item, 'Ship it');
|
|
const first = controller.comment(item, 'Ship it');
|
|
const duplicate = controller.comment(item, 'Ship it');
|
|
const during = controller.loadDraft(item);
|
|
releaseComment();
|
|
Promise.all([first, duplicate]).then(async comments => {{
|
|
const merges = await Promise.all([controller.merge(item, 'abc123'), controller.merge(item, 'abc123')]);
|
|
process.stdout.write(JSON.stringify({{calls, during, after:controller.loadDraft(item), comments, merges}}));
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["during"] == "Ship it"
|
|
assert output["after"] == ""
|
|
assert output["calls"] == [
|
|
{"url": "api/v1/repos/stackchain/api/pulls/7/comments", "method": "POST", "body": {"body": "Ship it"}},
|
|
{"url": "api/v1/repos/stackchain/api/pulls/7/merge", "method": "POST", "body": {"expected_head_sha": "abc123"}},
|
|
]
|
|
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
|
|
|
|
|
def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const data = {{
|
|
issues:[{{repository:'stackchain/api',number:7}}],
|
|
pulls:[
|
|
{{repository:'stackchain/api',number:7,title:'Transfer me'}},
|
|
{{repository:'stackchain/web',number:7,title:'Keep me'}}
|
|
]
|
|
}};
|
|
const item = {{repository:'stackchain/api',number:7}};
|
|
process.stdout.write(JSON.stringify({{
|
|
snapshot:createPullSheet.removeFromSnapshot(data, item),
|
|
same:createPullSheet.sameTarget(item, {{repository:'stackchain/api',number:7}}),
|
|
selectors:createPullSheet.ownershipSelectors()
|
|
}}));
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
assert output["snapshot"]["issues"] == [{"repository": "stackchain/api", "number": 7}]
|
|
assert output["snapshot"]["pulls"] == [{"repository": "stackchain/web", "number": 7, "title": "Keep me"}]
|
|
assert output["same"] is True
|
|
assert output["selectors"] == [
|
|
"#release-pull", "#load-pull-handoff", "#pull-handoff-recipient", "#confirm-pull-handoff"
|
|
]
|
|
|
|
|
|
def test_pull_sheet_ownership_presenters_render_candidates_and_exit_messages():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const children = [];
|
|
const elements = new Map([
|
|
['#pull-ownership', {{open:true}}],
|
|
['#pull-handoff-recipient', {{innerHTML:'stale',disabled:false}}],
|
|
['#confirm-pull-handoff', {{disabled:false,textContent:''}}],
|
|
['#load-pull-handoff', {{disabled:true}}],
|
|
['#release-pull', {{disabled:true,textContent:''}}],
|
|
['#pull-handoff-status', {{textContent:''}}],
|
|
]);
|
|
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
|
|
const doc = {{
|
|
createElement:() => ({{value:'',textContent:''}}),
|
|
querySelector:selector => elements.get(selector)
|
|
}};
|
|
const count = createPullSheet.renderHandoffCandidates(select, [
|
|
{{login:'alex',name:'Alexander'}}, {{login:'sam',name:'sam'}}
|
|
], doc);
|
|
createPullSheet.resetOwnershipControls(doc, {{key:'stackchain/api#7'}}, () => true);
|
|
const item = {{key:'stackchain/api#7'}};
|
|
process.stdout.write(JSON.stringify({{
|
|
count, children,
|
|
reset:Object.fromEntries(elements),
|
|
handed:createPullSheet.ownershipExitMessage(item, 'handed off to @alex', 'opened'),
|
|
released:createPullSheet.ownershipExitMessage(item, 'released', 'gated')
|
|
}}));
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
assert output["count"] == 2
|
|
assert output["children"] == [
|
|
{"value": "", "textContent": "Select a teammate"},
|
|
{"value": "alex", "textContent": "Alexander (@alex)"},
|
|
{"value": "sam", "textContent": "sam"},
|
|
]
|
|
assert output["reset"]["#pull-ownership"]["open"] is False
|
|
assert output["reset"]["#confirm-pull-handoff"]["textContent"] == "Hand off & next"
|
|
assert output["reset"]["#release-pull"]["textContent"] == "Release & next"
|
|
assert output["reset"]["#pull-handoff-status"]["textContent"] == "Load teammates to transfer ownership."
|
|
assert output["handed"] == "stackchain/api#7 handed off to @alex. Next work item opened."
|
|
assert output["released"] == "stackchain/api#7 released. Choose the next ready Today item."
|
|
|
|
|
|
def test_pull_sheet_single_flights_handoff_and_release_ownership_mutations():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const calls = [];
|
|
let finish;
|
|
const controller = createPullSheet({{
|
|
storage: null,
|
|
fetchJson: (url, options={{}}) => {{
|
|
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
|
|
if (url.endsWith('/handoff')) return new Promise(resolve => {{ finish = resolve; }});
|
|
return Promise.resolve(url.endsWith('/handoff-candidates') ? [{{login:'alex',name:'Alexander'}}] : {{assignees:[]}});
|
|
}}
|
|
}});
|
|
const item = {{repository:'stackchain/api',number:7}};
|
|
(async () => {{
|
|
const candidates = await controller.loadHandoffCandidates(item);
|
|
const first = controller.handoff(item, 'alex');
|
|
const second = controller.handoff(item, 'alex');
|
|
finish({{assignees:['alex'],recipient:'alex'}});
|
|
const handoffs = await Promise.all([first, second]);
|
|
const released = await controller.release(item);
|
|
process.stdout.write(JSON.stringify({{calls,candidates,handoffs,released}}));
|
|
}})();
|
|
"""
|
|
output = json.loads(subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout)
|
|
|
|
assert output["calls"] == [
|
|
{"url": "api/v1/repos/stackchain/api/pulls/7/handoff-candidates", "method": "GET", "body": None},
|
|
{"url": "api/v1/repos/stackchain/api/pulls/7/handoff", "method": "PATCH", "body": {"recipient": "alex"}},
|
|
{"url": "api/v1/repos/stackchain/api/pulls/7/release", "method": "PATCH", "body": None},
|
|
]
|
|
assert output["candidates"] == [{"login": "alex", "name": "Alexander"}]
|
|
assert output["handoffs"] == [
|
|
{"assignees": ["alex"], "recipient": "alex"},
|
|
{"assignees": ["alex"], "recipient": "alex"},
|
|
]
|
|
assert output["released"] == {"assignees": []}
|
|
|
|
|
|
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const controller = createPullSheet({{
|
|
storage: null,
|
|
fetchJson: () => Promise.resolve({{
|
|
number: 7,
|
|
merged: false,
|
|
state: 'unknown',
|
|
confirmation_pending: true,
|
|
error: 'Merge confirmation is pending. Check its status before retrying.',
|
|
}}),
|
|
}});
|
|
controller.merge({{repository:'stackchain/api', number:7}}, 'abc123')
|
|
.then(() => process.stdout.write(JSON.stringify({{resolved:true}})))
|
|
.catch(error => process.stdout.write(JSON.stringify({{resolved:false, message:error.message}})));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"resolved": False,
|
|
"message": "Merge confirmation is pending. Check its status before retrying.",
|
|
}
|
|
|
|
|
|
def test_pull_sheet_enables_merge_only_for_current_safe_state():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const states = [
|
|
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
|
|
{{state:'open', draft:true, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
|
|
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc', checks:[
|
|
{{name:'lint', state:'failure'}}, {{name:'release', state:'pending'}}, {{name:'build', state:'success'}}
|
|
]}},
|
|
{{state:'open', draft:false, mergeable:false, merged:false, ci_state:'success', head_sha:'abc'}},
|
|
];
|
|
process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
assert output[0] == {"allowed": True, "reason": "Ready to merge"}
|
|
assert output[1]["allowed"] is False and "draft" in output[1]["reason"].lower()
|
|
assert output[2] == {"allowed": False, "reason": "CI blocked by lint, release"}
|
|
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
|
|
|
|
|
|
def test_pull_sheet_persists_head_scoped_file_review_and_gates_merge():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const detail = {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc', files:[
|
|
{{filename:'src/api.py'}}, {{filename:'frontend/app.js'}}
|
|
]}};
|
|
const first = createPullSheet({{storage, fetchJson:()=>Promise.resolve()}});
|
|
const before = first.reviewState(item, detail);
|
|
const afterOne = first.toggleReviewed(item, detail, 'src/api.py');
|
|
const restored = createPullSheet({{storage, fetchJson:()=>Promise.resolve()}}).reviewState(item, detail);
|
|
const complete = first.toggleReviewed(item, detail, 'frontend/app.js');
|
|
const changedHead = first.reviewState(item, {{...detail, head_sha:'def'}});
|
|
process.stdout.write(JSON.stringify({{
|
|
before, afterOne, restored, complete, changedHead,
|
|
blocked:createPullSheet.mergeEligibility(detail, afterOne),
|
|
allowed:createPullSheet.mergeEligibility(detail, complete),
|
|
next:first.nextUnreviewed(detail, afterOne),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["before"] == {"reviewed": [], "total": 2, "complete": False}
|
|
assert output["afterOne"]["reviewed"] == ["src/api.py"]
|
|
assert output["restored"] == output["afterOne"]
|
|
assert output["complete"]["complete"] is True
|
|
assert output["changedHead"]["reviewed"] == []
|
|
assert output["blocked"] == {"allowed": False, "reason": "Review every changed file before merging"}
|
|
assert output["allowed"] == {"allowed": True, "reason": "Ready to merge"}
|
|
assert output["next"] == "frontend/app.js"
|
|
|
|
|
|
def test_pull_sheet_lazy_review_is_single_flight_cached_by_head_and_retryable():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const calls = [];
|
|
let rejectFirst;
|
|
const fetchJson = url => {{
|
|
calls.push(url);
|
|
if (calls.length === 1) return new Promise((_resolve, reject) => {{ rejectFirst = reject; }});
|
|
return Promise.resolve({{head_sha:'abc123', files:[]}});
|
|
}};
|
|
const sheet = createPullSheet({{fetchJson, storage:null}});
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const first = sheet.loadReview(item, 'abc123');
|
|
const concurrent = sheet.loadReview(item, 'abc123');
|
|
rejectFirst(new Error('diff unavailable'));
|
|
Promise.allSettled([first, concurrent]).then(async failed => {{
|
|
const retried = await sheet.loadReview(item, 'abc123');
|
|
const cached = await sheet.loadReview(item, 'abc123');
|
|
process.stdout.write(JSON.stringify({{
|
|
same:first === concurrent,
|
|
failed:failed.map(result => result.status),
|
|
retried,
|
|
cached,
|
|
calls,
|
|
}}));
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["same"] is True
|
|
assert output["failed"] == ["rejected", "rejected"]
|
|
assert output["retried"]["head_sha"] == "abc123"
|
|
assert output["cached"] == output["retried"]
|
|
assert output["calls"] == [
|
|
"api/v1/repos/stackchain/api/pulls/7/review-data",
|
|
"api/v1/repos/stackchain/api/pulls/7/review-data",
|
|
]
|
|
|
|
|
|
def test_pull_sheet_refreshes_status_only_without_clearing_review_progress():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v)}};
|
|
const calls = [];
|
|
const sheet = createPullSheet({{storage, fetchJson: url => {{
|
|
calls.push(url);
|
|
return Promise.resolve({{head_sha:'abc123', ci_state:calls.length === 1 ? 'pending' : 'success',
|
|
checks:[{{name:'lint', state:calls.length === 1 ? 'pending' : 'success'}}],
|
|
files:[{{filename:'src/api.py'}}]}});
|
|
}}}});
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
sheet.loadReview(item, 'abc123').then(first => {{
|
|
sheet.toggleReviewed(item, first, 'src/api.py');
|
|
return sheet.loadChecks(item).then(refreshed => {{
|
|
const combined = {{...first, ...refreshed}};
|
|
process.stdout.write(JSON.stringify({{calls, refreshed, progress:sheet.reviewState(item, combined)}}));
|
|
}});
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [
|
|
"api/v1/repos/stackchain/api/pulls/7/review-data",
|
|
"api/v1/repos/stackchain/api/pulls/7/checks",
|
|
]
|
|
assert output["refreshed"]["ci_state"] == "success"
|
|
assert output["progress"] == {"reviewed": ["src/api.py"], "total": 1, "complete": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_assigned_pull_sheet_exposes_mobile_ownership_exit_and_today_continuation():
|
|
html = await dashboard()
|
|
|
|
assert 'id="pull-ownership"' in html
|
|
assert 'id="pull-handoff-recipient"' in html
|
|
assert 'id="load-pull-handoff"' in html
|
|
assert 'id="confirm-pull-handoff"' in html
|
|
assert 'id="release-pull"' in html
|
|
assert 'id="pull-handoff-status" class="small" aria-live="assertive"' in html
|
|
assert '.pull-ownership select, .pull-ownership button { min-height:44px;' in html
|
|
assert "createPullSheet.bindOwnershipControls(" in html
|
|
assert "document, pullController, () => selectedPull" in html
|
|
assert "createPullSheet.removeFromSnapshot(lastContextSnapshot" in html
|
|
assert "if (continuing) return await completeOwnershipExitToday(item)" in html
|
|
assert "createPullSheet.resetOwnershipControls(document, item" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
|
|
html = await dashboard()
|
|
|
|
body = html.index('id="pull-sheet-body"')
|
|
conversation = html.index('<h2>Full conversation</h2>', body)
|
|
composer = html.index('id="pull-comment-title"', conversation)
|
|
review = html.index('id="pull-review"', composer)
|
|
files = html.index('id="pull-files"', review)
|
|
|
|
assert '<summary><h2>Review & merge</h2></summary>' in html
|
|
assert '<details class="pull-review" id="pull-review">' in html
|
|
assert 'id="pull-review-retry"' in html
|
|
assert 'id="pull-review-status"' in html
|
|
assert body < conversation < composer < review < files
|
|
|
|
|
|
def test_pull_sheet_renders_mobile_diff_fallbacks_and_review_controls():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const escapeHtml = value => String(value).replaceAll('&', '&').replaceAll('<', '<');
|
|
const text = createPullSheet.renderFile({{
|
|
filename:'src/<unsafe>.py', status:'modified', additions:1, deletions:1,
|
|
diff_available:true, diff_lines:['@@ -1 +1 @@', '-old', '+new'], diff_truncated:true
|
|
}}, 0, false, escapeHtml);
|
|
const binary = createPullSheet.renderFile({{
|
|
filename:'static/logo.png', diff_available:false, diff_binary:true, diff_truncated:false
|
|
}}, 1, true, escapeHtml);
|
|
process.stdout.write(JSON.stringify({{text, binary}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert 'aria-controls="pull-diff-0"' in output["text"]
|
|
assert "<unsafe>" in output["text"] and "+new" in output["text"]
|
|
assert "Preview truncated" in output["text"]
|
|
assert 'data-pull-review-file="src/<unsafe>.py"' in output["text"]
|
|
assert "Binary file · preview unavailable" in output["binary"]
|
|
assert 'aria-pressed="true"' in output["binary"] and "Reviewed" in output["binary"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
|
|
html = await dashboard()
|
|
|
|
assert 'id="pull-sheet"' in html and 'aria-modal="true"' in html
|
|
assert 'class="my-work-card-main pull-trigger"' in html
|
|
assert 'id="pull-sheet-status"' in html
|
|
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
|
|
assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
|
|
assert 'id="next-unreviewed-pull-file"' in html
|
|
assert 'id="load-older-pull-comments"' in html
|
|
assert 'id="pull-conversation-status"' in html and 'aria-live="assertive"' in html
|
|
assert '<script src="static/conversation.js"></script>' in html
|
|
assert "pullController.conversation(item, detail.conversation)" in html
|
|
assert "pullConversation.loadOlder()" in html
|
|
assert "pullConversation.append(comment)" in html
|
|
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
|
|
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
|
|
assert 'stackchain-feature-pull-workflow' in html
|
|
assert "pullWorkflowFeatures.run('pull-workflow'" in html
|
|
assert "let pullController = null" in html
|
|
assert "let reviewController = null" in html
|
|
assert "pullController.load(item)" in html
|
|
assert "createPullSheet.renderFile" in html
|
|
assert "pullController.toggleReviewed" in html
|
|
assert "createPullSheet.focusNextUnreviewed(document" in html
|
|
assert ".pull-diff { overflow-x:auto;" in html
|
|
assert ".pull-file-toggle, .pull-review-file { min-height:44px;" in html
|
|
assert "window.confirm('Merge ' + selectedPull.key" in html
|
|
assert "expected_head_sha" in html
|
|
assert "item.kind === 'pull'" in html and "pull-trigger" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
|
|
html = await dashboard()
|
|
|
|
assert 'id="issue-sheet"' in html and 'aria-modal="true"' in html
|
|
assert 'class="my-work-card-main issue-trigger"' in html
|
|
assert 'id="close-issue-sheet"' in html
|
|
assert 'id="retry-issue-load"' in html
|
|
assert 'id="issue-sheet-body"' in html
|
|
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
|
|
assert 'id="issue-comments"' in html
|
|
assert 'id="load-older-issue-comments"' in html
|
|
assert 'id="issue-conversation-status"' in html and 'aria-live="assertive"' in html
|
|
assert "issueController.conversation(item, detail.conversation)" in html
|
|
assert "issueConversation.loadOlder()" in html
|
|
assert "issueConversation.append(comment)" in html
|
|
assert '.conversation-more { min-height:44px;' in html
|
|
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
|
|
assert 'id="send-issue-comment"' in html
|
|
assert 'id="close-issue"' in html
|
|
assert 'id="open-issue-gitea"' in html and 'rel="noopener noreferrer"' in html
|
|
assert '.issue-sheet-panel { width:min(560px,100%);' in html
|
|
assert '.issue-sheet-content { overflow-wrap:anywhere;' in html
|
|
assert '.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px;' in html
|
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
|
assert '<script src="static/issue-sheet.js"></script>' in html
|
|
assert "issueController.load(item)" in html
|
|
assert "issueController.comment(selectedIssue" in html
|
|
assert "window.confirm('Close ' + selectedIssue.key + '?')" in html
|
|
assert "issueController.close(selectedIssue)" in html
|
|
assert "lastMyWork = lastMyWork.filter" in html
|
|
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
|
|
assert "e.key === 'Escape' && selectedIssue" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_sheet_releases_assignment_with_confirmed_local_removal():
|
|
html = await dashboard()
|
|
|
|
assert 'id="release-issue"' in html
|
|
assert 'Release assignment' in html
|
|
assert "window.confirm('Release ' + selectedIssue.key + ' from your My Work?')" in html
|
|
assert "issueController.release(selectedIssue, lastContextSnapshot?.user?.login)" in html
|
|
assert "buildMyWork.removeIssue(" in html
|
|
|
|
|
|
def test_remove_issue_updates_snapshot_without_mutating_other_work():
|
|
payload = {
|
|
"issues": [
|
|
{"repository": "stackchain/api", "number": 17},
|
|
{"repository": "stackchain/web", "number": 18},
|
|
],
|
|
"pull_requests": [{"repository": "stackchain/api", "number": 17}],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const original = {json.dumps(payload)};
|
|
const updated = buildMyWork.removeIssue(original, 'stackchain/api', 17);
|
|
process.stdout.write(JSON.stringify({{updated, original}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
assert output["updated"]["issues"] == [{"repository": "stackchain/web", "number": 18}]
|
|
assert output["updated"]["pull_requests"] == payload["pull_requests"]
|
|
assert output["original"]["issues"] == payload["issues"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
|
|
html = await dashboard()
|
|
|
|
assert 'id="issue-label-editor"' in html
|
|
assert 'aria-describedby="issue-label-status"' in html
|
|
assert 'id="issue-label-list"' in html
|
|
assert 'id="save-issue-labels"' in html
|
|
assert '.issue-label-option { min-height:44px;' in html
|
|
assert 'max-width:100%;' in html
|
|
assert "issueController.updateLabels(selectedIssue" in html
|
|
assert "buildMyWork.replaceIssueLabels" in html
|
|
assert "paintMyWork(lastContextSnapshot)" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet():
|
|
html = await dashboard()
|
|
|
|
assert 'id="new-issue"' in html and 'New issue' in html
|
|
assert 'id="create-issue-sheet"' in html and 'aria-modal="true"' in html
|
|
assert 'id="create-issue-repository"' in html
|
|
assert 'id="create-issue-title"' in html and 'maxlength="255"' in html
|
|
assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html
|
|
assert 'id="create-issue-labels"' in html and 'aria-describedby="create-issue-label-status"' in html
|
|
assert 'id="create-issue-label-status"' in html and 'aria-live="polite"' in html
|
|
assert 'id="submit-new-issue"' in html
|
|
assert '.create-issue-sheet.open { display:flex; }' in html
|
|
assert 'height:100dvh;' in html
|
|
assert 'env(safe-area-inset-bottom)' in html
|
|
assert '<script src="static/create-issue-sheet.js"></script>' in html
|
|
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
|
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
|
assert 'openRoutedWork(created' in html
|
|
assert 'issueCapture.bindFilingMetadata({' in html
|
|
assert "input[name=\"create-issue-label\"]:checked" in html
|
|
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
|
|
|
|
|
def test_new_issue_capture_waits_for_retryable_feature_before_opening():
|
|
dashboard_source = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
|
|
|
assert "await issueCaptureFeatures.run('issue-capture'" in dashboard_source
|
|
assert "if (!issueCapture)" in dashboard_source
|
|
assert "async function openCreateIssueSheet" in dashboard_source
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
|
html = await dashboard()
|
|
|
|
assert 'id="create-issue-duplicates"' in html
|
|
assert 'id="create-issue-duplicate-list"' in html
|
|
assert 'aria-live="polite"' in html
|
|
assert 'id="create-issue-anyway"' in html and 'Create anyway' in html
|
|
assert "issueCapture.findDuplicates(draft)" in html
|
|
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
|
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
|
submit_handler = html.split("qs('#create-issue-form').addEventListener('submit'", 1)[1].split(
|
|
"qs('#close-issue-sheet').addEventListener", 1
|
|
)[0]
|
|
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in submit_handler
|
|
assert "filingReview.open" in submit_handler
|
|
assert "issueOutbox.enqueueDurably" not in submit_handler
|
|
assert ".create-issue-duplicate-card" in html
|
|
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
|
assert "overflow-wrap:anywhere" in html
|
|
|
|
|
|
@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 "const sessionItems = workSession.items()" 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-comments"' 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 "reader:notificationReader, items:()=>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_accessible_sticky_mobile_subset_selection():
|
|
html = await dashboard()
|
|
|
|
assert 'id="select-updates"' in html
|
|
assert 'id="cancel-update-selection"' in html
|
|
assert 'id="update-selection-status"' in html
|
|
assert 'aria-live="polite"' in html
|
|
assert 'class="update-selector"' in html
|
|
assert 'type="checkbox"' in html
|
|
assert 'Select update ' in html
|
|
assert '.update-selector { min-height:44px;' 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 '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
|
assert "'Mark ' + selection.count + ' selected read'" in html
|
|
assert "'Confirm marking ' + selection.count + ' selected read'" in html
|
|
assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
|
|
assert 'notificationSelection.retain(result.failed)' in html
|
|
assert "notificationSelection.cancel()" 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_selected_updates_can_be_deferred_together_without_marking_them_read():
|
|
html = await dashboard()
|
|
|
|
assert 'id="defer-selected-today"' in html
|
|
assert 'id="defer-selected-tomorrow"' in html
|
|
assert 'laterWork.deferMany(selectedUpdates, until)' in html
|
|
assert "notificationSelection.cancel()" in html
|
|
assert "Selection unchanged." in html
|
|
assert "work stays unread and unchanged in Gitea" in html
|
|
assert '.my-work-bulk-actions { display:grid;' in html
|
|
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_work_queues_batch_plan_cross_kind_selection():
|
|
html = await dashboard()
|
|
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
|
|
|
assert '<script src="static/work-selection.js"></script>' in html
|
|
assert 'id="select-work"' in html
|
|
assert 'id="batch-add-today"' in html
|
|
assert 'createWorkSelection({ limit: 50' in html
|
|
assert 'todayWork.addMany(selectedItems)' in html
|
|
assert 'laterWork.deferMany(selectedItems, until)' in html
|
|
assert 'data-select-work-id=' in html
|
|
assert 'Select work ' in html
|
|
assert "workSelectionState.count + ' selected'" in html
|
|
assert '.work-selector { min-height:44px;' in html
|
|
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
|
assert "BASE + 'static/work-selection.js'" in service_worker
|
|
|
|
|
|
@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 "createNotificationSelection({" in html
|
|
assert "limit: 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="today"' in html
|
|
assert 'data-work-count="attention"' 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 'data-work-count="later"' in html
|
|
assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
|
assert 'data-work-count="draft"' 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_controller_refreshes_checks_through_status_only_path():
|
|
script = f"""
|
|
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
|
const calls = [];
|
|
const controller = createReviewController({{ fetchJson: async (url, options) => {{
|
|
calls.push({{url, accept:options.headers.Accept}});
|
|
return {{head_sha:'abc123', ci_state:'success', checks:[]}};
|
|
}} }});
|
|
controller.loadChecks({{repository:'stackchain/api', number:7}}).then(status =>
|
|
process.stdout.write(JSON.stringify({{calls, status}}))
|
|
);
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["calls"] == [{
|
|
"url": "api/v1/repos/stackchain/api/pulls/7/review/checks",
|
|
"accept": "application/json",
|
|
}]
|
|
assert output["status"]["ci_state"] == "success"
|
|
|
|
|
|
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 'class="review-diff-line review-inline-target removed"' in output["html"]
|
|
assert 'data-old-position="1"' in output["html"]
|
|
assert 'data-new-position="1"' in output["html"]
|
|
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old">1</span><span class="review-line-number new"></span></span>' in output["html"]
|
|
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old"></span><span class="review-line-number new">1</span></span>' in output["html"]
|
|
assert '<span class="review-line-code">-old <token></span>' in output["html"]
|
|
assert '<span class="review-line-code">+new & safe</span>' in output["html"]
|
|
assert 'aria-label="Comment on src/<api>.py line 1"' in output["html"]
|
|
assert 'Mark reviewed' in output["html"]
|
|
assert output["expanded"] == "true"
|
|
assert output["hidden"] is False
|
|
|
|
|
|
def test_review_checks_render_blockers_first_with_safe_touch_links():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const escapeHtml = value => String(value)
|
|
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
const result = reviewSheet.renderChecks([
|
|
{{name:'build', state:'success', description:'Passed'}},
|
|
{{name:'lint <mobile>', state:'failure', description:'Open & fix', url:'https://forge.example/jobs/4'}},
|
|
{{name:'release', state:'pending', description:'Waiting'}},
|
|
], escapeHtml, {{offline:false}});
|
|
const offline = reviewSheet.renderChecks([
|
|
{{name:'lint', state:'success', description:'Passed'}},
|
|
], escapeHtml, {{offline:true}});
|
|
process.stdout.write(JSON.stringify({{result, offline}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
output = json.loads(result.stdout)
|
|
|
|
assert output["result"]["summary"] == "1 failed · 1 pending · 1 passed"
|
|
assert output["result"]["expanded"] is True
|
|
assert output["result"]["html"].index("lint <mobile>") < output["result"]["html"].index("build")
|
|
assert 'href="https://forge.example/jobs/4"' in output["result"]["html"]
|
|
assert 'target="_blank" rel="noopener noreferrer"' in output["result"]["html"]
|
|
assert "Open job" in output["result"]["html"]
|
|
assert output["offline"]["summary"].startswith("Last known · ")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
|
|
html = await dashboard()
|
|
|
|
assert html.index('id="pull-checks"') < html.index('id="pull-files"')
|
|
assert html.index('id="review-checks"') < html.index('id="review-files"')
|
|
assert 'id="refresh-pull-checks"' in html
|
|
assert 'id="refresh-review-checks"' in html
|
|
assert "createReviewController.renderChecks" in html
|
|
assert "pullController.loadChecks(item)" in html
|
|
assert "reviewController.loadChecks(item)" in html
|
|
assert "New commits detected. Reload review data before merging." in html
|
|
assert "New commits detected. Reload the review before submitting feedback." in html
|
|
assert "qs('#refresh-review-checks').disabled = offlineReview" in html
|
|
assert ".ci-check-link" in html and "min-height:44px" in html
|
|
assert ".ci-check-copy" in html and "overflow-wrap:anywhere" in html
|
|
|
|
|
|
def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem: key => values.has(key) ? values.get(key) : null,
|
|
setItem: (key, value) => values.set(key, value),
|
|
}};
|
|
const phone = reviewSheet.createWrapPreference({{storage, mobile: true}});
|
|
const initial = phone.snapshot();
|
|
const disabled = phone.setWrapped(false);
|
|
const restoredPhone = reviewSheet.createWrapPreference({{storage, mobile: true}}).snapshot();
|
|
const restoredDesktop = reviewSheet.createWrapPreference({{storage, mobile: false}}).snapshot();
|
|
process.stdout.write(JSON.stringify({{initial, disabled, restoredPhone, restoredDesktop,
|
|
stored: values.get('stackchain.review-wrap.v1')}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"initial": {"wrapped": True, "explicit": False},
|
|
"disabled": {"wrapped": False, "explicit": True},
|
|
"restoredPhone": {"wrapped": False, "explicit": True},
|
|
"restoredDesktop": {"wrapped": False, "explicit": True},
|
|
"stored": "false",
|
|
}
|
|
|
|
|
|
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const rows = reviewSheet.parseDiffLines([
|
|
'@@ -10,3 +20,4 @@ function run()',
|
|
' context', '-removed', '+added', '+second',
|
|
String.raw`\\ No newline at end of file`,
|
|
'@@ -40 +51 @@', '-old tail', '+new tail'
|
|
]);
|
|
process.stdout.write(JSON.stringify(rows));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == [
|
|
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
|
|
{"text": " context", "kind": "context", "commentable": True, "old_line": 10, "new_line": 20, "new_position": 20},
|
|
{"text": "-removed", "kind": "removed", "commentable": True, "old_line": 11, "old_position": 11},
|
|
{"text": "+added", "kind": "added", "commentable": True, "new_line": 21, "new_position": 21},
|
|
{"text": "+second", "kind": "added", "commentable": True, "new_line": 22, "new_position": 22},
|
|
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
|
|
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
|
|
{"text": "-old tail", "kind": "removed", "commentable": True, "old_line": 40, "old_position": 40},
|
|
{"text": "+new tail", "kind": "added", "commentable": True, "new_line": 51, "new_position": 51},
|
|
]
|
|
|
|
|
|
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."},
|
|
"comments": [],
|
|
"summary": "One blocker remains.",
|
|
"decision": "request_changes",
|
|
}
|
|
|
|
|
|
def test_review_draft_persists_edits_and_removes_inline_comments_for_same_head():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
|
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'src/a.py'}}]}};
|
|
const first = reviewSheet.createDraft(options);
|
|
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle empty values.');
|
|
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle null and empty values.');
|
|
first.setInlineComment({{path:'src/a.py', old_position:9}}, 'Why remove this guard?');
|
|
const restored = reviewSheet.createDraft(options);
|
|
const beforeRemove = restored.snapshot().comments;
|
|
const afterRemove = restored.removeInlineComment({{path:'src/a.py', old_position:9}}).comments;
|
|
process.stdout.write(JSON.stringify({{beforeRemove, afterRemove}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"beforeRemove": [
|
|
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
|
|
{"path": "src/a.py", "body": "Why remove this guard?", "old_position": 9},
|
|
],
|
|
"afterRemove": [
|
|
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
|
|
],
|
|
}
|
|
|
|
|
|
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_mobile_review_wraps_long_lines_with_gutters_and_persisted_toggle():
|
|
html = await dashboard()
|
|
|
|
assert 'id="review-wrap-lines"' in html
|
|
assert 'aria-pressed="false"' in html
|
|
assert 'aria-controls="review-files"' in html
|
|
assert "createReviewController.createWrapPreference" in html
|
|
assert "matchMedia('(max-width: 600px)').matches" in html
|
|
assert "reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped)" in html
|
|
assert "wrapPreference.setWrapped" in html
|
|
assert '.review-display-tools button' in html and 'min-height:44px' in html
|
|
assert '.review-line-numbers' in html and 'grid-template-columns:4ch 4ch' in html
|
|
assert '.review-line-code' in html and 'overflow-wrap:anywhere' in html
|
|
assert '.wrap-lines .review-diff' in html and 'overflow-x:hidden' in html
|
|
assert '.wrap-lines .review-diff-line' in html and 'min-width:0' in html
|
|
assert '.wrap-lines .review-line-code' in html and 'white-space:pre-wrap' in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
|
|
html = await dashboard()
|
|
|
|
assert "const reviewOutbox = item.outbox_kind === 'pull-review';" in html
|
|
assert "reviewOutbox && item.status === 'attention'" in html
|
|
assert ">Open current review</button>" in html
|
|
assert ">Copy feedback</button>" in html
|
|
assert "reviewOutbox && item.status === 'authorization'" in html
|
|
assert ">Authorize & send review</button>" in html
|
|
assert "Review submitted and queued intent cleared." in html
|
|
assert "item.kind === 'authored-outbox' && !reviewOutbox" 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 'id="submit-review"' in html
|
|
assert 'id="review-submit-status"' in html and 'aria-live="assertive"' in html
|
|
assert "reviewController.submit(selectedReview" in html
|
|
assert "window.confirm" in html
|
|
assert "expected_head_sha: selectedReviewHead" in html
|
|
assert "await load()" 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_sheet_edits_inline_drafts_and_submits_them_with_review():
|
|
html = await dashboard()
|
|
|
|
assert 'id="review-inline-composer"' in html
|
|
assert 'id="review-inline-body"' in html and 'maxlength="10000"' in html
|
|
assert 'id="save-inline-comment"' in html
|
|
assert 'id="delete-inline-comment"' in html
|
|
assert '.review-inline-target' in html and 'min-height:44px' in html
|
|
assert '.review-inline-composer' in html and 'position:sticky' in html
|
|
assert "document.querySelectorAll('.review-inline-target')" in html
|
|
assert "draft.setInlineComment" in html
|
|
assert "draft.removeInlineComment" in html
|
|
assert "comments: snapshot.comments" in html
|
|
|
|
|
|
def test_review_controller_submits_once_while_request_is_in_flight():
|
|
script = f"""
|
|
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
|
let resolveRequest;
|
|
const calls = [];
|
|
const controller = createReviewController({{ createOperationId: () => 'review-op-test', fetchJson: (url, options) => {{
|
|
calls.push({{url, options}});
|
|
return new Promise(resolve => {{ resolveRequest = resolve; }});
|
|
}} }});
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const payload = {{decision:'approve', body:'Looks good.', expected_head_sha:'abc123'}};
|
|
const first = controller.submit(item, payload);
|
|
const second = controller.submit(item, payload);
|
|
resolveRequest({{id:91, state:'APPROVED'}});
|
|
Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{
|
|
calls, results
|
|
}})));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
payload = json.loads(result.stdout)
|
|
assert len(payload["calls"]) == 1
|
|
assert payload["calls"][0] == {
|
|
"url": "api/v1/repos/stackchain/api/pulls/7/review",
|
|
"options": {
|
|
"method": "POST",
|
|
"headers": {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
"Idempotency-Key": "review-op-test",
|
|
},
|
|
"body": json.dumps(
|
|
{
|
|
"decision": "approve",
|
|
"body": "Looks good.",
|
|
"expected_head_sha": "abc123",
|
|
},
|
|
separators=(",", ":"),
|
|
),
|
|
},
|
|
}
|
|
assert payload["results"] == [
|
|
{"id": 91, "state": "APPROVED"},
|
|
{"id": 91, "state": "APPROVED"},
|
|
]
|
|
|
|
|
|
def test_successful_review_can_clear_head_scoped_draft_and_progress():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const removed = [];
|
|
const storage = {{
|
|
getItem: key => values.get(key) || null,
|
|
setItem: (key, value) => values.set(key, value),
|
|
removeItem: key => {{ removed.push(key); values.delete(key); }},
|
|
}};
|
|
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'a.py'}}]}};
|
|
const draft = reviewSheet.createDraft(options);
|
|
const progress = reviewSheet.createProgress(options);
|
|
draft.setSummary('Looks good.');
|
|
progress.markReviewed('a.py');
|
|
draft.clear();
|
|
progress.clear();
|
|
process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:progress.snapshot()}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
payload = json.loads(result.stdout)
|
|
assert payload["removed"] == [
|
|
"stackchain.review-draft.v1:stackchain/api#7@abc123",
|
|
"stackchain.review-progress.v1:stackchain/api#7@abc123",
|
|
]
|
|
assert payload["draft"] == {
|
|
"notes": {}, "comments": [], "summary": "", "decision": "comment"
|
|
}
|
|
assert payload["progress"]["reviewedCount"] == 0
|
|
|
|
|
|
def test_approved_assigned_review_carries_exact_head_progress_into_merge():
|
|
script = f"""
|
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem: key => values.get(key) || null,
|
|
setItem: (key, value) => values.set(key, value),
|
|
}};
|
|
const assigned = {{
|
|
repository:'stackchain/api', number:7,
|
|
work_reasons:['assigned_to_me', 'review_requested']
|
|
}};
|
|
const carried = reviewSheet.prepareMergeContinuation({{
|
|
storage, item:assigned, headSha:'abc123',
|
|
reviewed:['a.py', 'b.py'], decision:'approve'
|
|
}});
|
|
const unassigned = reviewSheet.prepareMergeContinuation({{
|
|
storage, item:{{...assigned, number:8, work_reasons:['review_requested']}},
|
|
headSha:'def456', reviewed:['c.py'], decision:'approve'
|
|
}});
|
|
const changesRequested = reviewSheet.prepareMergeContinuation({{
|
|
storage, item:{{...assigned, number:9}}, headSha:'ghi789',
|
|
reviewed:['d.py'], decision:'request_changes'
|
|
}});
|
|
process.stdout.write(JSON.stringify({{carried, unassigned, changesRequested, entries:[...values.entries()]}}));
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"carried": True,
|
|
"unassigned": False,
|
|
"changesRequested": False,
|
|
"entries": [[
|
|
"stackchain.pull-review.v1:stackchain/api#7:abc123",
|
|
'["a.py","b.py"]',
|
|
]],
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_successful_assigned_approval_continues_to_merge_in_place():
|
|
html = await dashboard()
|
|
|
|
assert 'id="continue-review-to-merge"' in html
|
|
assert '#continue-review-to-merge[hidden]' in html and 'display:none' in html
|
|
assert '.review-merge-continuation' in html and 'position:sticky' in html
|
|
assert '#continue-review-to-merge { min-height:44px;' in html
|
|
assert "createReviewController.prepareMergeContinuation" in html
|
|
assert "reviewed: progressSnapshot.reviewed" in html
|
|
assert "decision: snapshot.decision" in html
|
|
assert "workRoute.open({ ...item, kind:'pull', is_review:false }, { replace:true })" in html
|
|
assert "qs('#continue-review-to-merge').hidden = false" in html
|
|
assert "qs('#continue-review-to-merge').focus()" 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
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation():
|
|
html = await dashboard()
|
|
|
|
update_sheet = html[html.index('id="update-sheet"'):html.index('id="pull-sheet"')]
|
|
assert '<h2>Full conversation</h2>' in update_sheet
|
|
assert 'id="update-comments"' in update_sheet
|
|
assert 'id="load-older-update-comments"' in update_sheet
|
|
assert 'id="update-conversation-status"' in update_sheet
|
|
assert '.conversation-more' in html and 'min-height:44px' in html
|
|
assert "'/conversation?limit=20' + pageQuery" in html
|
|
assert 'id="retry-update-conversation"' in update_sheet
|
|
assert "notificationReader.retryConversation()" in html
|
|
assert "loadConversation: fetchNotificationConversation" in html
|
|
assert "onConversation: renderUpdateConversation" in html
|
|
assert "notificationReader.loadOlder()" in html
|
|
assert "notificationReader.appendReply(result)" in html
|
|
|
|
|
|
def test_issue_comment_reuses_operation_key_after_reload_until_success():
|
|
script = f"""
|
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
|
const calls = [];
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const first = createIssueSheet({{storage, createOperationId:() => 'issue-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
|
|
first.comment(item, 'Ship it').catch(() => {{
|
|
const second = createIssueSheet({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:82}});}}}});
|
|
second.comment(item, 'Ship it').then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
assert json.loads(result.stdout) == {"calls": ["issue-op-185", "issue-op-185"], "keys": []}
|
|
|
|
|
|
def test_pull_comment_reuses_operation_key_after_reload_until_success():
|
|
script = f"""
|
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
|
const calls = [];
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const first = createPullSheet({{storage, createOperationId:() => 'pull-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
|
|
first.comment(item, 'Ship it').catch(() => {{
|
|
createPullSheet({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:91}});}}}}).comment(item, 'Ship it')
|
|
.then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
assert json.loads(result.stdout) == {"calls": ["pull-op-185", "pull-op-185"], "keys": []}
|
|
|
|
|
|
def test_notification_reply_reuses_persisted_operation_key():
|
|
script = f"""
|
|
const build = require({json.dumps(str(MY_WORK))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
|
const calls = [];
|
|
const item = {{notification_id:42}};
|
|
const first = build.createNotificationReplier({{storage, onStatus:()=>{{}}, createOperationId:() => 'reply-op-185', post:(_id,_body,key) => {{calls.push(key); return Promise.reject(new Error('timeout'));}}}});
|
|
first.submit(item, 'Retry').then(() => {{
|
|
const second = build.createNotificationReplier({{storage, onStatus:()=>{{}}, createOperationId:() => 'wrong-key', post:(_id,_body,key) => {{calls.push(key); return Promise.resolve({{id:91}});}}}});
|
|
second.submit(item, 'Retry').then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
assert json.loads(result.stdout) == {"calls": ["reply-op-185", "reply-op-185"], "keys": []}
|
|
|
|
|
|
def test_review_submission_reuses_persisted_operation_key():
|
|
script = f"""
|
|
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
|
const values = new Map();
|
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
|
const calls = [];
|
|
const item = {{repository:'stackchain/api', number:7}};
|
|
const payload = {{decision:'approve', body:'Good', expected_head_sha:'abc', comments:[]}};
|
|
const first = createReviewController({{storage, createOperationId:() => 'review-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
|
|
first.submit(item, payload).catch(() => {{
|
|
createReviewController({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:93}});}}}}).submit(item, payload)
|
|
.then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
|
|
}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []}
|
|
|
|
def test_filed_queue_contains_authored_issues_and_deduplicates_self_assigned_filings():
|
|
payload = {
|
|
"user": {"login": "timmy"},
|
|
"issues": [
|
|
{"id": 1, "repository": "stackchain/api", "number": 1, "title": "Delegated", "assignees": ["alex"], "work_reasons": ["created_by_me"]},
|
|
{"id": 2, "repository": "stackchain/api", "number": 2, "title": "Mine", "assignees": ["timmy"], "work_reasons": ["created_by_me"]},
|
|
{"id": 3, "repository": "stackchain/api", "number": 3, "title": "Assigned only", "assignees": ["timmy"]},
|
|
],
|
|
"pull_requests": [],
|
|
}
|
|
script = f"""
|
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
const items = buildMyWork({json.dumps(payload)});
|
|
process.stdout.write(JSON.stringify({{
|
|
filed: buildMyWork.filterMyWork(items, 'filed').map(item => item.number).sort((a,b) => a-b),
|
|
count: buildMyWork.countMyWork(items).filed,
|
|
identities: items.map(item => item.key).sort(),
|
|
}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
assert json.loads(result.stdout) == {
|
|
"filed": [1, 2], "count": 2,
|
|
"identities": ["stackchain/api#1", "stackchain/api#2", "stackchain/api#3"],
|
|
}
|