194 lines
8.1 KiB
Python
194 lines
8.1 KiB
Python
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
|