feat: protect private work before sign out (Closes #1128)
All checks were successful
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m16s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-19 11:37:27 +00:00
parent 3b4f6b39a5
commit c2526969b1
13 changed files with 457 additions and 21 deletions

View File

@ -57,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys - name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py 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 run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_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
release-candidate: release-candidate:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -6,6 +6,15 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; } .toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; } .app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; } .app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.sign-out-review-sheet[hidden] { display:none; }
.sign-out-review-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.sign-out-review-panel h2 { margin:.25rem 0; }
.sign-out-review-warning { color:#fbbf24; }
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.sign-out-review-actions button { min-height:44px; width:100%; }
#confirm-sign-out { border-color:#b45309; }
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } .live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.live-data-status-sheet[hidden] { display:none; } .live-data-status-sheet[hidden] { display:none; }
.live-data-status-panel { box-sizing:border-box; width:min(560px,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; } .live-data-status-panel { box-sizing:border-box; width:min(560px,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; }

View File

@ -27,6 +27,17 @@
</div> </div>
</details> </details>
</header> </header>
<div id="sign-out-review-sheet" class="sign-out-review-sheet" hidden>
<section class="sign-out-review-panel" role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading">
<h2 id="sign-out-review-heading">Review sign out</h2>
<p id="sign-out-review-summary" class="small" role="status" aria-live="polite">Checking private work…</p>
<p id="sign-out-review-warning" class="sign-out-review-warning"></p>
<div class="sign-out-review-actions">
<button id="cancel-sign-out" type="button">Cancel</button>
<button id="confirm-sign-out" type="button">Sign out</button>
</div>
</section>
</div>
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden> <div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading"> <section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
<div class="live-data-status-header"> <div class="live-data-status-header">
@ -1816,6 +1827,8 @@
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div> <div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/private-data-registry.js"></script> <script src="static/private-data-registry.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/sign-out-review.js"></script>
<script src="static/session.js"></script> <script src="static/session.js"></script>
<script src="static/feature-loader.js"></script> <script src="static/feature-loader.js"></script>
<script src="static/conversation-action-hydrator.js"></script> <script src="static/conversation-action-hydrator.js"></script>
@ -1923,7 +1936,6 @@
<script src="static/mobile-insights.js"></script> <script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script> <script src="static/mobile-app-shortcuts.js"></script>
<script src="static/install-app.js"></script> <script src="static/install-app.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/private-device-data.js"></script> <script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script> <script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script> <script src="static/mobile-device-setup.js"></script>

View File

@ -81,7 +81,8 @@
revoke.type = 'button'; revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke'; revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => { revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut(); if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
else if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) { else if (await boundary.revokeActiveDevice(device)) {
await Promise.all([renderDevices(), renderSecurityActivity()]); await Promise.all([renderDevices(), renderSecurityActivity()]);
} }

View File

@ -15,6 +15,7 @@ const SHELL = [
BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png', BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js', BASE + 'static/session.js',
BASE + 'static/sign-out-review.js',
BASE + 'static/feature-loader.js', BASE + 'static/feature-loader.js',
BASE + 'static/workspace-bootstrap.js', BASE + 'static/workspace-bootstrap.js',
BASE + 'static/conversation-action-hydrator.js', BASE + 'static/conversation-action-hydrator.js',

View File

@ -38,9 +38,25 @@
boundary.startActivityHeartbeat(); boundary.startActivityHeartbeat();
boundary.startReconnectResume(); boundary.startReconnectResume();
const button = root.document.getElementById('sign-out'); const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut()); const allButton = root.document.getElementById('sign-out-all');
const allDevicesButton = root.document.getElementById('sign-out-all'); let review;
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices()); const features = root.createFeatureLoader({
document: root.document,
urls: {
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
},
});
const openReview = (mode, source) => features.run(
'sign-out', { trigger: source }, () => {
if (!review) review = root.createSignOutReview.mount(boundary);
review.show(mode, source);
},
);
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
source?.addEventListener('click', () => openReview(mode, source))
);
boundary.requestSignOut = source => openReview('current', source || button);
const devicesButton = root.document.getElementById('active-devices'); const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet'); const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesStatus = root.document.getElementById('active-devices-status'); const devicesStatus = root.document.getElementById('active-devices-status');
@ -51,14 +67,6 @@
root.stackchainTodayTimerView?.beginDetour?.('security-center'); root.stackchainTodayTimerView?.beginDetour?.('security-center');
} }
}; };
const securityFeatures = root.createFeatureLoader({
document: root.document,
urls: {
'security-center': root.document.querySelector(
'meta[name="stackchain-feature-security-center"]'
)?.content || '',
},
});
let loadingSecurityCenter = false; let loadingSecurityCenter = false;
const openSecurityCenter = async () => { const openSecurityCenter = async () => {
if (loadingSecurityCenter) return; if (loadingSecurityCenter) return;
@ -67,7 +75,7 @@
devicesSheet.hidden = false; devicesSheet.hidden = false;
closeDevices?.focus(); closeDevices?.focus();
try { try {
await securityFeatures.run('security-center', { await features.run('security-center', {
trigger: devicesButton, trigger: devicesButton,
status: devicesStatus, status: devicesStatus,
retryLabel: 'Tap Active devices to retry.', retryLabel: 'Tap Active devices to retry.',
@ -607,8 +615,8 @@
} }
} }
async function signOutAllDevices() { async function signOutAllDevices({ reviewed = false } = {}) {
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.'); const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
if (!confirmed) return false; if (!confirmed) return false;
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' }); const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
if (!response.ok) throw new Error('Could not sign out all devices'); if (!response.ok) throw new Error('Could not sign out all devices');

131
frontend/sign-out-review.js Normal file
View File

@ -0,0 +1,131 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else {
root.createSignOutReview = factory;
root.createSignOutReview.mount = boundary => {
const document = root.document;
const review = factory({
sheet: document.getElementById('sign-out-review-sheet'),
heading: document.getElementById('sign-out-review-heading'),
summary: document.getElementById('sign-out-review-summary'),
warning: document.getElementById('sign-out-review-warning'),
cancelButton: document.getElementById('cancel-sign-out'),
confirmButton: document.getElementById('confirm-sign-out'),
escapeTarget: document,
historyTarget: root,
history: root.history,
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
getActiveElement: () => document.activeElement,
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
privateDatabases: root.stackchainPrivateDatabases,
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
onConfirm: mode => mode === 'all'
? boundary.signOutAllDevices({ reviewed: true })
: boundary.signOut(),
});
review.start();
return review;
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
let mode = 'current';
let launcher = null;
let open = false;
let historyEntry = false;
function ownedItemCount(storage) {
if (!storage) return 0;
let count = 0;
for (let index = 0; index < storage.length; index += 1) {
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
}
return count;
}
async function show(nextMode, source) {
mode = nextMode;
launcher = source;
const itemCount = ownedItemCount(options.localStorage)
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
let inventory = { recordCount: 0, unavailable: true };
try {
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
options.summary.textContent = inventory.unavailable
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
if (mode === 'all') {
options.warning.textContent = atRisk
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
} else {
options.warning.textContent = atRisk
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
: 'Signing out clears this device after the session is revoked.';
}
options.confirmButton.textContent = atRisk
? 'Sign out and erase private work'
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
options.sheet.hidden = false;
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
open = true;
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
historyEntry = Boolean(options.history?.back);
options.cancelButton.focus();
}
function close({ restoreFocus = true } = {}) {
if (!open) return;
options.sheet.hidden = true;
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
open = false;
if (restoreFocus) launcher?.focus?.();
}
function dismiss() {
if (historyEntry) {
historyEntry = false;
options.history.back();
} else close();
}
async function confirm() {
options.confirmButton.disabled = true;
try {
await options.onConfirm(mode);
close({ restoreFocus: false });
} catch (_error) {
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
} finally {
options.confirmButton.disabled = false;
}
}
function start() {
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
options.cancelButton?.addEventListener('click', dismiss);
options.confirmButton?.addEventListener('click', confirm);
options.escapeTarget?.addEventListener('keydown', event => {
if (!open) return;
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
if (event.key !== 'Tab') return;
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = options.getActiveElement?.();
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
});
options.historyTarget?.addEventListener('popstate', () => {
historyEntry = false;
close();
});
}
return { start, show, close };
});

View File

@ -27,13 +27,14 @@ FEATURE_SOURCES = {
), ),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
"push-notifications": ("static/push-notifications.js",), "push-notifications": ("static/push-notifications.js",),
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.js"),
"device-setup": ( "device-setup": (
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js", "static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
"static/device-storage.js", "static/mobile-device-setup.js", "static/device-storage.js", "static/mobile-device-setup.js",
), ),
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"today-timer": ( "today-timer": (
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",

View File

@ -0,0 +1,76 @@
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 mobile sign-out 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
def test_release_artifact_reviews_private_work_before_mobile_sign_out(tmp_path: Path):
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))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
delete_requests: list[str] = []
browser_errors: list[str] = []
try:
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": 390, "height": 844}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"request",
lambda request: delete_requests.append(request.url)
if request.method == "DELETE" and "/api/v1/session" in request.url
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Sign-out review 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("localStorage.setItem('stackchain.release-test-draft', 'unsynced')")
launcher = page.locator("#sign-out")
page.locator("#app-menu-toggle").click()
launcher.click()
sheet = page.locator("#sign-out-review-sheet")
expect(sheet).to_be_visible()
expect(page.locator("#sign-out-review-summary")).to_contain_text("private browser item")
expect(page.locator("#sign-out-review-warning")).to_contain_text("may not be synced")
expect(page.locator("#confirm-sign-out")).to_have_text("Sign out and erase private work")
assert page.evaluate("document.querySelector('main').inert") is True
panel = page.locator(".sign-out-review-panel").bounding_box()
assert panel and panel["height"] <= 844
for selector in ("#cancel-sign-out", "#confirm-sign-out"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert delete_requests == []
page.keyboard.press("Escape")
expect(sheet).to_be_hidden()
expect(launcher).to_be_focused()
assert page.evaluate("document.querySelector('main').inert") is False
assert page.evaluate("localStorage.getItem('stackchain.release-test-draft')") == "unsynced"
assert delete_requests == []
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)

View File

@ -63,6 +63,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
"tests/e2e/test_mobile_search_preview_navigation.py " "tests/e2e/test_mobile_search_preview_navigation.py "
"tests/e2e/test_mobile_find_work_release.py " "tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py " "tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_sign_out_release.py "
"tests/e2e/test_mobile_today_handoff_release.py " "tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py " "tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py " "tests/e2e/test_mobile_today_summary_release.py "

View File

@ -36,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
changed_frontend = tmp_path / "frontend" changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend) shutil.copytree(FRONTEND, changed_frontend)
(changed_frontend / "widgets.js").write_text( (changed_frontend / "session.js").write_text(
(changed_frontend / "widgets.js").read_text() + "\n// changed\n" (changed_frontend / "session.js").read_text() + "\n// changed\n"
) )
changed = build_frontend(changed_frontend) changed = build_frontend(changed_frontend)
@ -49,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND) first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == { assert set(first.feature_bundles) == {
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup", "comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
"today-timer", "security-center", "today-timer", "security-center",
} }
assert first.dashboard_html.count("<script src=") == 1 assert first.dashboard_html.count("<script src=") == 1
@ -75,6 +75,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createSignOutReview" not in first.runtime_bytes
assert b"function createSignOutReview" in first.feature_bundles["sign-out"].runtime_bytes
assert b"function createDetailDefer" not in first.runtime_bytes assert b"function createDetailDefer" not in first.runtime_bytes
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes

View File

@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/icons/stackchain-192.png", "/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png", "/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js", "/dashboard/static/session.js",
"/dashboard/static/sign-out-review.js",
"/dashboard/static/feature-loader.js", "/dashboard/static/feature-loader.js",
"/dashboard/static/workspace-bootstrap.js", "/dashboard/static/workspace-bootstrap.js",
"/dashboard/static/conversation-action-hydrator.js", "/dashboard/static/conversation-action-hydrator.js",

View File

@ -0,0 +1,193 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "sign-out-review.js"
def run_scenario(scenario: str, *, inventory: str = "{recordCount:3, unavailable:false}") -> dict:
harness = r"""
const createSignOutReview = require(__MODULE__);
class Target {
constructor() { this.listeners={}; this.hidden=false; this.textContent=''; this.disabled=false; this.dataset={}; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event={}) { for (const callback of this.listeners[name] || []) await callback({preventDefault() { state.prevented = true; }, shiftKey:false, currentTarget:this, ...event}); }
focus() { state.focused=this; }
contains(target) { return target === this; }
querySelectorAll() { return [cancelButton, confirmButton]; }
}
const state={signedOut:[], focused:null, historyPushes:0, historyBacks:0};
const launcher=new Target();
const allLauncher=new Target();
const sheet=new Target(); sheet.hidden=true;
const heading=new Target();
const summary=new Target();
const warning=new Target();
const cancelButton=new Target();
const confirmButton=new Target();
const escapeTarget=new Target();
const historyTarget=new Target();
const background=new Target(); background.inert=false;
const storage={
values:new Map([['stackchain.private','secret'],['stackchain.draft','draft'],['gitea.preference','keep']]),
get length() { return this.values.size; },
key(index) { return Array.from(this.values.keys())[index] || null; },
};
const history={
pushState() { state.historyPushes += 1; },
back() { state.historyBacks += 1; historyTarget.dispatch('popstate'); },
};
const review=createSignOutReview({
launcher, allLauncher, sheet, heading, summary, warning, cancelButton, confirmButton,
escapeTarget, historyTarget, history, localStorage:storage, sessionStorage:storage,
backgroundTargets:[background], getActiveElement:() => state.focused,
privateDatabases:['private-work'], inspectPrivateDatabases:async () => (__INVENTORY__),
onConfirm:async mode => { if (state.failConfirm) throw new Error('blocked purge'); state.signedOut.push(mode); },
});
(async () => { review.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__INVENTORY__", inventory).replace("__SCENARIO__", scenario)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_private_work_is_counted_before_sign_out_and_requires_sheet_confirmation():
result = run_scenario("""
await launcher.dispatch('click');
const before=[...state.signedOut];
await confirmButton.dispatch('click');
process.stdout.write(JSON.stringify({
before, after:state.signedOut, hidden:sheet.hidden, heading:heading.textContent,
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
}));
""")
assert result == {
"before": [],
"after": ["current"],
"hidden": True,
"heading": "Review sign out",
"summary": "This device has 2 private browser items and 3 private work records.",
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
"action": "Sign out and erase private work",
}
def test_keyboard_focus_is_contained_inside_open_review():
result = run_scenario("""
await launcher.dispatch('click');
state.focused=confirmButton;
await escapeTarget.dispatch('keydown', {key:'Tab'});
const wrappedForward=state.focused === cancelButton;
state.focused=cancelButton;
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true});
process.stdout.write(JSON.stringify({wrappedForward, wrappedBackward:state.focused === confirmButton, prevented:state.prevented || false}));
""")
assert result == {"wrappedForward": True, "wrappedBackward": True, "prevented": True}
def test_cancel_removes_review_history_and_preserves_session_and_private_work():
result = run_scenario("""
await launcher.dispatch('click');
await cancelButton.dispatch('click');
process.stdout.write(JSON.stringify({
signedOut:state.signedOut, hidden:sheet.hidden, historyPushes:state.historyPushes,
historyBacks:state.historyBacks, launcherFocused:state.focused === launcher,
}));
""")
assert result == {
"signedOut": [], "hidden": True, "historyPushes": 1,
"historyBacks": 1, "launcherFocused": True,
}
def test_open_review_makes_background_inert_until_cancelled():
result = run_scenario("""
await launcher.dispatch('click');
const inertWhileOpen=background.inert;
await cancelButton.dispatch('click');
process.stdout.write(JSON.stringify({inertWhileOpen, inertAfterCancel:background.inert}));
""")
assert result == {"inertWhileOpen": True, "inertAfterCancel": False}
def test_sign_out_all_review_combines_global_session_and_local_work_warning():
result = run_scenario("""
await allLauncher.dispatch('click');
const before=[...state.signedOut];
await confirmButton.dispatch('click');
process.stdout.write(JSON.stringify({
before, after:state.signedOut, heading:heading.textContent,
warning:warning.textContent, action:confirmButton.textContent,
}));
""")
assert result == {
"before": [], "after": ["all"],
"heading": "Review sign out on every device",
"warning": "Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.",
"action": "Sign out and erase private work",
}
def test_failed_inventory_never_claims_the_device_is_empty():
result = run_scenario("""
await launcher.dispatch('click');
process.stdout.write(JSON.stringify({
signedOut:state.signedOut, hidden:sheet.hidden,
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
}));
""", inventory="Promise.reject(new Error('blocked'))")
assert result == {
"signedOut": [], "hidden": False,
"summary": "This device has 2 private browser items; private work status is unknown.",
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
"action": "Sign out and erase private work",
}
def test_failed_purge_keeps_review_open_with_recovery_guidance():
result = run_scenario("""
await launcher.dispatch('click');
state.failConfirm=true;
try { await confirmButton.dispatch('click'); } catch (_error) {}
process.stdout.write(JSON.stringify({
signedOut:state.signedOut, hidden:sheet.hidden, disabled:confirmButton.disabled,
warning:warning.textContent,
}));
""")
assert result == {
"signedOut": [], "hidden": False, "disabled": False,
"warning": "Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.",
}
def test_dashboard_renders_and_wires_phone_safe_sign_out_review():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
session = (root / "frontend" / "session.js").read_text()
review = MODULE.read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
assert 'id="sign-out-review-sheet"' in html
assert 'role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading"' in html
assert 'id="sign-out-review-summary"' in html
assert 'id="sign-out-review-warning"' in html
assert 'id="cancel-sign-out"' in html
assert 'id="confirm-sign-out"' in html
assert html.index('static/private-data-inventory.js') < html.index('static/session.js')
assert html.index('static/sign-out-review.js') < html.index('static/session.js')
assert "root.createSignOutReview" in session
assert "inspectPrivateDatabases: root.inspectStackchainPrivateDatabases" in review
assert "stackchain-feature-sign-out" in session
assert "max-height:100dvh" in css
assert ".sign-out-review-actions button { min-height:44px" in css
assert "env(safe-area-inset-bottom)" in css
assert "BASE + 'static/sign-out-review.js'" in worker