feat: share private Today summaries (Closes #1090)
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m9s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-18 18:39:01 +00:00
1 parent 5f86d48e6f
commit b3a359760c
16 files changed
+556 -11

No files matched your search

@@ -0,0 +1,111 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Today summary journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.add_init_script(
"Object.defineProperty(navigator, 'share', {configurable:true, value:async payload => { window.__sharedSummary = payload; }});"
)
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
lambda message: browser_errors.append(message.text)
if message.type == "error"
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Today summary release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""
() => {
const summary=createTodaySummary({
storage:localStorage,getLogin:()=> 'timmy',
share:payload=>navigator.share(payload),
copy:text=>navigator.clipboard.writeText(text),
});
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__summaryTest={summary,view};
view.open(
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}],
);
}
"""
)
sheet = page.locator("#today-summary-sheet")
expect(sheet).to_be_visible()
expect(page.locator("#today-summary-preview")).to_have_text(
"Today\n- Ship mobile capture\n\nTomorrow\n- Review release"
)
assert "42m" not in page.locator("#today-summary-preview").inner_text()
for control in page.locator(".today-summary-actions button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate(
"""
() => {
window.__summaryTest.summary.includeActuals(true);
window.__summaryTest.summary.setNote('Waiting on review');
window.__summaryTest.view.render();
}
"""
)
expect(page.locator("#today-summary-preview")).to_contain_text(
"Ship mobile capture (42m)"
)
page.evaluate(
"window.__summaryTest.view.shareDraft(document.querySelector('#share-today-summary'))"
)
expect(sheet).to_be_hidden()
assert page.evaluate("window.__sharedSummary") == {
"title": "Today summary",
"text": "Today\n- Ship mobile capture (42m)\n\nTomorrow\n- Review release\n\nNote\nWaiting on review",
}
assert page.evaluate(
"localStorage.getItem('stackchain.today-summary-draft.v1.timmy')"
) is None
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+1
View File
@@ -65,6 +65,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser
assert "needs: [lint, build-release, browser-journey]" in release
+1
View File
@@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/today-session-sync.js",
"/dashboard/static/today-recap.js",
"/dashboard/static/today-wrap-up.js",
"/dashboard/static/today-summary.js",
"/dashboard/static/today-handoff.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
+2 -2
View File
@@ -880,9 +880,9 @@ async def test_dashboard_renders_mobile_today_recap_flow():
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
assert 'class="today-recap-variance"' in recap_source
assert "await recap.saveForReplan()" in recap_source
assert "openWrapUp(handoff.actual_minutes)" in recap_source
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap_source
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard
assert "todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)" in dashboard
assert ".today-recap-row { grid-template-columns:1fr; }" in css
+155
View File
@@ -0,0 +1,155 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
TODAY_SUMMARY = ROOT / "frontend" / "today-summary.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_summary_begins_with_exact_worked_and_tomorrow_items_but_keeps_time_private():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
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 summary = createSummary({{storage,getLogin:()=> ' Timmy '}});
const snapshot = summary.begin(
[{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}],
[{{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}}],
);
process.stdout.write(JSON.stringify({{
snapshot,
text:summary.text(),
keys:[...values.keys()],
}}));
"""
output = run_node(script)
assert output["snapshot"]["worked"] == [{
"identity": "issue:acme/mobile:41:",
"title": "Ship mobile capture",
"context": "acme/mobile#41",
"actual_minutes": 42,
"include": True,
}]
assert output["snapshot"]["tomorrow"] == [{
"identity": "issue:acme/mobile:42:",
"title": "Review release",
"context": "acme/mobile#42",
"include": True,
}]
assert output["snapshot"]["include_actuals"] is False
assert output["text"] == "Today\n- Ship mobile capture\n\nTomorrow\n- Review release"
assert output["keys"] == ["stackchain.today-summary-draft.v1.timmy"]
def test_summary_privacy_choices_and_note_survive_reload_for_the_same_account():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
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 options = {{storage,getLogin:()=> 'timmy'}};
const summary = createSummary(options);
summary.begin(
[{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}],
[{{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}}],
);
summary.includeActuals(true);
summary.setNote(' Waiting on review ');
summary.choose('tomorrow', 'issue:acme/mobile:42:', false);
const restored = createSummary(options);
process.stdout.write(JSON.stringify({{
restored:restored.restore(),
snapshot:restored.snapshot(),
text:restored.text(),
}}));
"""
output = run_node(script)
assert output["restored"] is True
assert output["snapshot"]["include_actuals"] is True
assert output["snapshot"]["note"] == "Waiting on review"
assert output["snapshot"]["tomorrow"][0]["include"] is False
assert output["text"] == "Today\n- Ship mobile capture (42m)\n\nNote\nWaiting on review"
def test_summary_shares_natively_or_once_to_clipboard_and_keeps_canceled_drafts():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
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 row = [{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}];
const canceled = createSummary({{storage,getLogin:()=> 'timmy',share:async()=>{{const error=new Error('cancel');error.name='AbortError';throw error;}}}});
canceled.begin(row, []);
const canceledResult = await canceled.shareSummary();
const keptAfterCancel = values.has('stackchain.today-summary-draft.v1.timmy');
const copies = [];
const fallback = createSummary({{storage,getLogin:()=> 'timmy',copy:async text=>copies.push(text)}});
fallback.restore();
const copiedResult = await fallback.shareSummary();
process.stdout.write(JSON.stringify({{
canceledResult, keptAfterCancel, copiedResult, copies,
keptAfterCopy:values.has('stackchain.today-summary-draft.v1.timmy'),
}}));
"""
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert output["canceledResult"] == {"status": "canceled"}
assert output["keptAfterCancel"] is True
assert output["copiedResult"] == {"status": "copied"}
assert output["copies"] == ["Today\n- Ship mobile capture"]
assert output["keptAfterCopy"] is False
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
from src import main
html = main.FRONTEND_BUILD.dashboard_html
css = (ROOT / "frontend" / "dashboard.css").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
recap = (ROOT / "frontend" / "today-recap.js").read_text()
wrap_up = (ROOT / "frontend" / "today-wrap-up.js").read_text()
assert 'id="today-summary-sheet" role="dialog"' in html
assert 'id="today-summary-worked"' in html
assert 'id="today-summary-tomorrow"' in html
assert 'id="today-summary-include-actuals"' in html
assert 'id="share-today-summary"' in html
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
assert "setupTodaySummary({" in dashboard
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
assert ".today-summary-actions button { min-height:44px;" in css
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
assert "tests/e2e/test_mobile_today_summary_release.py" in (
ROOT / ".gitea" / "workflows" / "ci.yml"
).read_text()
def test_summary_uses_the_human_label_from_recap_feedback_rows():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
const summary = createSummary();
summary.begin([{{
identity:'issue:acme/mobile:41:', label:'Ship mobile capture',
context:'acme/mobile#41', actual_minutes:42,
}}], []);
process.stdout.write(JSON.stringify({{text:summary.text()}}));
"""
assert run_node(script)["text"] == "Today\n- Ship mobile capture"
+1 -1
View File
@@ -69,7 +69,7 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
assert 'setupTodayWrapUp({' in dashboard
assert 'todayWrapUpView.open' in dashboard
assert 'openWrapUp(handoff.actual_minutes)' in recap
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
assert '.today-wrap-up-actions button { min-height:44px;' in css
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()