Protect private work before mobile sign-out #1129
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- 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:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
.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; }
|
||||
.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[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; }
|
||||
|
|
|
|||
|
|
@ -27,6 +27,17 @@
|
|||
</div>
|
||||
</details>
|
||||
</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>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
|
|
@ -1816,6 +1827,8 @@
|
|||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<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/feature-loader.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-app-shortcuts.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/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@
|
|||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
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)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const SHELL = [
|
|||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/sign-out-review.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/workspace-bootstrap.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
|
|
|
|||
|
|
@ -38,9 +38,25 @@
|
|||
boundary.startActivityHeartbeat();
|
||||
boundary.startReconnectResume();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const allButton = root.document.getElementById('sign-out-all');
|
||||
let review;
|
||||
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 devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
|
|
@ -51,14 +67,6 @@
|
|||
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;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
|
|
@ -67,7 +75,7 @@
|
|||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
await features.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
|
|
@ -607,8 +615,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
async function signOutAllDevices({ reviewed = false } = {}) {
|
||||
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
|
|
|
|||
131
frontend/sign-out-review.js
Normal file
131
frontend/sign-out-review.js
Normal 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 };
|
||||
});
|
||||
|
|
@ -27,13 +27,14 @@ FEATURE_SOURCES = {
|
|||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.js"),
|
||||
"device-setup": (
|
||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"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-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",
|
||||
|
|
|
|||
76
tests/e2e/test_mobile_sign_out_release.py
Normal file
76
tests/e2e/test_mobile_sign_out_release.py
Normal 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)
|
||||
|
|
@ -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_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 "
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
|||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "widgets.js").write_text(
|
||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
||||
(changed_frontend / "session.js").write_text(
|
||||
(changed_frontend / "session.js").read_text() + "\n// changed\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
|
|
@ -49,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
|||
first = build_frontend(FRONTEND)
|
||||
|
||||
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",
|
||||
}
|
||||
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 createMobileDeliveryRecovery" not in first.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" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
|
|
|
|||
|
|
@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/sign-out-review.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/workspace-bootstrap.js",
|
||||
"/dashboard/static/conversation-action-hydrator.js",
|
||||
|
|
|
|||
193
tests/test_sign_out_review.py
Normal file
193
tests/test_sign_out_review.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user