Guide mobile operators through device readiness setup #556
|
|
@ -18,6 +18,16 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
|||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
.device-setup-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.device-setup-header h2, .device-setup-header p { margin-top:0; }
|
||||
.device-setup-header button, .device-setup-action { min-height:44px; }
|
||||
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
|
||||
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
|
|
@ -463,6 +473,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
.my-work { margin:0; }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
|
|
|
|||
|
|
@ -418,6 +418,7 @@
|
|||
urls: {
|
||||
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
|
||||
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
|
||||
'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '',
|
||||
},
|
||||
});
|
||||
let sharedLaunchState = null;
|
||||
|
|
@ -5066,20 +5067,25 @@
|
|||
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
});
|
||||
}
|
||||
keepWorkOffline.addEventListener('change', async () => {
|
||||
offlineWorkStore.setEnabled(keepWorkOffline.checked);
|
||||
if (keepWorkOffline.checked && liveMode && lastContextSnapshot) {
|
||||
async function setOfflineWorkEnabled(enabled) {
|
||||
keepWorkOffline.checked = enabled;
|
||||
offlineWorkStore.setEnabled(enabled);
|
||||
if (enabled && liveMode && lastContextSnapshot) {
|
||||
const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||||
notification_pagination:notificationPagination });
|
||||
if (admitted === false) {
|
||||
await updateOfflineWorkControls('Offline saving unavailable · retry after reconnect.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!keepWorkOffline.checked) await offlineWorkStore.clear();
|
||||
await updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
if (keepWorkOffline.checked) warmTodayOffline();
|
||||
if (!enabled) await offlineWorkStore.clear();
|
||||
await updateOfflineWorkControls(enabled ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||
if (enabled) warmTodayOffline();
|
||||
else offlineToday.cancel();
|
||||
return offlineWorkStore.enabled();
|
||||
}
|
||||
keepWorkOffline.addEventListener('change', async () => {
|
||||
await setOfflineWorkEnabled(keepWorkOffline.checked);
|
||||
});
|
||||
deliveryReceipts.addEventListener('change', async () => {
|
||||
let enabled = deliveryReceipts.checked;
|
||||
|
|
@ -5270,40 +5276,63 @@
|
|||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
});
|
||||
let pushControllerReady = Promise.resolve(null);
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||||
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||||
await issueCaptureFeatures.load('push-notifications');
|
||||
return createPushNotifications({
|
||||
const controller = createPushNotifications({
|
||||
control:qs('#push-updates'),
|
||||
status:qs('#push-update-status'),
|
||||
notification:window.Notification,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
fetchJson:fetchReviewJson,
|
||||
}).init();
|
||||
});
|
||||
await controller.init();
|
||||
return controller;
|
||||
}).catch(error => {
|
||||
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
|
||||
console.warn('Push notifications unavailable', error);
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
qs('#push-updates').disabled = true;
|
||||
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
|
||||
}
|
||||
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) &&
|
||||
!/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent);
|
||||
createInstallApp({
|
||||
window,
|
||||
card: qs('#install-app-card'),
|
||||
installButton: qs('#install-app'),
|
||||
dismissButton: qs('#dismiss-install-app'),
|
||||
guidance: qs('#install-app-guidance'),
|
||||
status: qs('#install-app-status'),
|
||||
storage: localStorage,
|
||||
isStandalone: () => window.matchMedia('(display-mode: standalone)').matches ||
|
||||
navigator.standalone === true,
|
||||
isIosSafari: () => isIosSafari,
|
||||
}).start();
|
||||
let deferredInstallPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', event => {
|
||||
event.preventDefault();
|
||||
deferredInstallPrompt = event;
|
||||
});
|
||||
let deviceSetup = null;
|
||||
qs('#open-device-setup').addEventListener('click', async event => {
|
||||
if (deviceSetup) return;
|
||||
await issueCaptureFeatures.load('device-setup');
|
||||
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const installApp = createInstallApp({
|
||||
window, initialPrompt:deferredInstallPrompt,
|
||||
card:qs('#install-app-card'), installButton:qs('#install-app'),
|
||||
dismissButton:qs('#dismiss-install-app'), guidance:qs('#install-app-guidance'),
|
||||
status:qs('#install-app-status'), storage:localStorage,
|
||||
isStandalone:() => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true,
|
||||
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
|
||||
});
|
||||
installApp.start();
|
||||
deviceSetup = createMobileDeviceSetup.mount({
|
||||
document, installApp,
|
||||
offlineAvailable:() => offlineStorageReady,
|
||||
offlineEnabled:() => offlineWorkStore.enabled(),
|
||||
enableOffline:() => setOfflineWorkEnabled(true),
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
qs('#push-updates').checked = true;
|
||||
await controller.change();
|
||||
},
|
||||
});
|
||||
deviceSetup.start();
|
||||
await deviceSetup.open(event);
|
||||
});
|
||||
contextPoller.start();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
<div class="app-menu-panel">
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="open-device-setup" type="button">Device setup</button>
|
||||
<button id="active-devices" type="button">Active devices</button>
|
||||
<button id="sign-out" type="button">Sign out & clear this device</button>
|
||||
<button id="sign-out-all" type="button">Sign out all devices</button>
|
||||
|
|
@ -59,6 +60,33 @@
|
|||
</section>
|
||||
</div>
|
||||
|
||||
<div id="device-setup-sheet" class="device-setup-sheet" hidden>
|
||||
<section class="device-setup-panel" role="dialog" aria-modal="true" aria-labelledby="device-setup-heading">
|
||||
<div class="device-setup-header">
|
||||
<div>
|
||||
<h2 id="device-setup-heading">Make this device work-ready</h2>
|
||||
<p class="small muted">Prepare this phone for fast launch, outages, and new update alerts. Nothing asks for permission until you choose it.</p>
|
||||
</div>
|
||||
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
|
||||
</div>
|
||||
<ol class="device-setup-list">
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-install" type="button">Install</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
</ol>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||
<div class="my-work-header">
|
||||
|
|
@ -776,6 +804,7 @@
|
|||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = null;
|
||||
let deferredPrompt = options.initialPrompt || null;
|
||||
let installed = false;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
installed = true;
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
|
|
@ -44,6 +46,7 @@
|
|||
hide();
|
||||
return;
|
||||
}
|
||||
if (deferredPrompt) showNativePrompt(deferredPrompt);
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
|
|
@ -51,6 +54,7 @@
|
|||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
|
|
@ -58,5 +62,18 @@
|
|||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
return {start};
|
||||
function state() {
|
||||
if (installed || options.isStandalone()) {
|
||||
return {state:'complete', detail:'Stackchain is installed on this device.'};
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
|
||||
}
|
||||
if (deferredPrompt) {
|
||||
return {state:'incomplete', detail:'Stackchain is ready to install.'};
|
||||
}
|
||||
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
|
||||
}
|
||||
|
||||
return {start, install, state};
|
||||
});
|
||||
|
|
|
|||
95
frontend/mobile-device-setup.js
Normal file
95
frontend/mobile-device-setup.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeviceSetup = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
async function render() {
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
if (step.state === 'complete') complete += 1;
|
||||
});
|
||||
options.readyStatus.textContent = complete === available
|
||||
? 'This device is ready.'
|
||||
: `${complete} of ${available} available steps ready.`;
|
||||
return readiness;
|
||||
}
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
await render();
|
||||
options.sheet.hidden = false;
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
try { await action(); }
|
||||
finally { await render(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {start, open, close, render};
|
||||
});
|
||||
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports.mount = mountMobileDeviceSetup;
|
||||
} else {
|
||||
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
|
||||
}
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
|
||||
escapeTarget:options.document,
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
});
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ const SHELL = [
|
|||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ FEATURE_SOURCES = {
|
|||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
tests/test_mobile_device_setup.py
Normal file
124
tests/test_mobile_device_setup.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user