feat: share private Today summaries (Closes #1090)
This commit is contained in:
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)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user