feat: guide mobile device readiness (Closes #555)
CI / lint (pull_request) Successful in 1m13s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 08:33:01 +00:00
1 parent a13e25f6c9
commit 797fd0d544
11 files changed
+386 -29

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications"}
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications", "device-setup"}
capture = first.feature_bundles["issue-capture"]
pull_workflow = first.feature_bundles["pull-workflow"]
assert b"function createIssueCapture" not in first.runtime_bytes
+47
View File
@@ -174,6 +174,53 @@ process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:va
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
def test_setup_api_reports_install_state_without_prompting():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const app = createInstallApp({
window:windowTarget, card:{hidden:true}, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
const before = app.state();
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
const after = app.state();
process.stdout.write(JSON.stringify({before, after}));
"""
)
assert result == {
"before": {"state": "unavailable", "detail": "Installation is available from a supported browser."},
"after": {"state": "incomplete", "detail": "Stackchain is ready to install."},
}
def test_captured_install_prompt_remains_available_until_setup_opens():
result = run_install_scenario(
"""
let prevented = 0;
const prompt = {preventDefault(){prevented += 1;}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})};
const card = {hidden:true};
const app = createInstallApp({
window:new FakeTarget(), initialPrompt:prompt, card,
installButton:new FakeButton(), dismissButton:new FakeButton(), guidance:{hidden:true},
status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
process.stdout.write(JSON.stringify({state:app.state(), shown:!card.hidden, prevented}));
"""
)
assert result == {
"state": {"state": "incomplete", "detail": "Stackchain is ready to install."},
"shown": True,
"prevented": 1,
}
def test_appinstalled_hides_an_visible_native_promotion():
result = run_install_scenario(
"""
+124
View File
@@ -0,0 +1,124 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js"
def run_scenario(script: str) -> dict:
harness = r"""
const createMobileDeviceSetup = require(__MODULE__);
class FakeTarget {
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
focus() { state.focused = this; }
}
const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null};
const launcher = new FakeTarget();
const closeButton = new FakeTarget();
const sheet = new FakeTarget(); sheet.hidden = true;
const installButton = new FakeTarget();
const offlineButton = new FakeTarget();
const pushButton = new FakeTarget();
const installStatus = new FakeTarget();
const offlineStatus = new FakeTarget();
const pushStatus = new FakeTarget();
const readyStatus = new FakeTarget();
const escapeTarget = new FakeTarget();
let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'},
push:{state:'unavailable', detail:'Notifications are unavailable.'},
};
const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
getReadiness:() => readiness,
install:async () => { state.installCalls += 1; },
enableOffline:async () => { state.offlineCalls += 1; },
enablePush:async () => { state.pushCalls += 1; },
});
(async () => { setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_opening_setup_reports_actual_readiness_without_triggering_permissions():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
install:installStatus.textContent,
offline:offlineStatus.textContent,
push:pushStatus.textContent,
summary:readyStatus.textContent,
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
}));
""")
assert result == {
"hidden": False,
"install": "Stackchain is installed.",
"offline": "Offline work is off.",
"push": "Notifications are unavailable.",
"summary": "1 of 2 available steps ready.",
"calls": [0, 0, 0],
}
def test_setup_action_rechecks_real_state_before_marking_step_ready():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
await offlineButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:state.offlineCalls,
offline:offlineStatus.textContent,
hidden:offlineButton.hidden,
summary:readyStatus.textContent,
}));
""")
assert result == {
"calls": 1,
"offline": "Offline work is saved.",
"hidden": True,
"summary": "This device is ready.",
}
def test_escape_closes_setup_and_restores_launcher_focus():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
await escapeTarget.dispatch('keydown', {key:'Escape'});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
launcherFocused:state.focused === launcher,
}));
""")
assert result == {"hidden": True, "launcherFocused": True}
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
assert 'id="open-device-setup"' in html
assert 'id="device-setup-sheet"' in html
assert 'aria-labelledby="device-setup-heading"' in html
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
assert '<script src="static/mobile-device-setup.js"></script>' in html
assert "createMobileDeviceSetup.mount({" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert ".device-setup-panel" in css
assert "overflow-x:hidden" in css
assert "env(safe-area-inset-bottom)" in css
assert ".device-setup-action { min-height:44px;" in css
+1
View File
@@ -502,6 +502,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js",