feat: keep mobile conversation replies keyboard-safe (#323)
This commit is contained in:
1 parent
160753caf8
commit
21e0a49d3c
8 files changed
+289
-4
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HTML = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mobile-composer-viewport.js"></script>' in html
|
||||
assert "createMobileComposerViewport({" in dashboard
|
||||
for panel, workspace, composer, submit, status in [
|
||||
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
|
||||
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
|
||||
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
|
||||
|
||||
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
html = HTML.read_text()
|
||||
worker = WORKER.read_text()
|
||||
local_assets = set(re.findall(r'(?:src|href)="((?:static/|manifest\.webmanifest)[^"?#]*)', html))
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v36" in worker
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
|
||||
|
||||
|
||||
def run_controller_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
class Target {{
|
||||
constructor() {{ this.listeners = {{}}; }}
|
||||
addEventListener(name, handler) {{ (this.listeners[name] ||= new Set()).add(handler); }}
|
||||
removeEventListener(name, handler) {{ this.listeners[name]?.delete(handler); }}
|
||||
dispatch(name, event = {{}}) {{ for (const handler of this.listeners[name] || []) handler(event); }}
|
||||
count(name) {{ return this.listeners[name]?.size || 0; }}
|
||||
}}
|
||||
function element() {{
|
||||
const target = new Target();
|
||||
target.style = {{
|
||||
values: {{}},
|
||||
setProperty(name, value) {{ this.values[name] = value; }},
|
||||
removeProperty(name) {{ delete this.values[name]; }},
|
||||
}};
|
||||
target.scrollTop = 0;
|
||||
target.reveals = [];
|
||||
target.scrollIntoView = options => target.reveals.push(options);
|
||||
target.contains = candidate => candidate === target || candidate?.workspace === target;
|
||||
return target;
|
||||
}}
|
||||
const viewport = new Target();
|
||||
viewport.offsetTop = 12;
|
||||
viewport.height = 360;
|
||||
const mediaQuery = {{matches:true}};
|
||||
const panel = element();
|
||||
const workspace = element();
|
||||
const composer = element(); composer.workspace = workspace;
|
||||
const submit = element(); submit.workspace = workspace;
|
||||
const status = element(); status.workspace = workspace;
|
||||
const document = {{activeElement:null}};
|
||||
const scheduled = [];
|
||||
const controller = createController({{
|
||||
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
|
||||
schedule: callback => scheduled.push(callback),
|
||||
}});
|
||||
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_composer_uses_visual_viewport_and_reveals_delivery_controls():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
viewport.offsetTop = 20;
|
||||
viewport.height = 300;
|
||||
viewport.dispatch('resize');
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
geometry: panel.style.values,
|
||||
resizeListeners: viewport.count('resize'),
|
||||
scrollListeners: viewport.count('scroll'),
|
||||
revealCount: workspace.reveals.length,
|
||||
revealOptions: workspace.reveals.at(-1),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"geometry": {
|
||||
"--composer-viewport-top": "20px",
|
||||
"--composer-viewport-height": "300px",
|
||||
},
|
||||
"resizeListeners": 1,
|
||||
"scrollListeners": 1,
|
||||
"revealCount": 2,
|
||||
"revealOptions": {"block": "end", "inline": "nearest"},
|
||||
}
|
||||
|
||||
|
||||
def test_blur_restores_scroll_and_close_clears_geometry_without_duplicate_listeners():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
composer.dispatch('focus');
|
||||
document.activeElement = null;
|
||||
workspace.dispatch('focusout');
|
||||
flush();
|
||||
const afterBlur = {scrollTop:panel.scrollTop, geometry:{...panel.style.values}, resize:viewport.count('resize')};
|
||||
panel.scrollTop = 99;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
controller.close(panel);
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
afterBlur,
|
||||
afterClose:{scrollTop:panel.scrollTop, geometry:panel.style.values, resize:viewport.count('resize'), scroll:viewport.count('scroll')},
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"afterBlur": {"scrollTop": 240, "geometry": {}, "resize": 0},
|
||||
"afterClose": {"scrollTop": 99, "geometry": {}, "resize": 0, "scroll": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_and_missing_visual_viewport_are_noop():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
mediaQuery.matches = false;
|
||||
controller.start();
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewport.count('resize'), reveals:workspace.reveals.length}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"geometry": {}, "resize": 0, "reveals": 0}
|
||||
@@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v35" in source
|
||||
assert "stackchain-dashboard-shell-v36" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v35" in source
|
||||
assert "stackchain-dashboard-shell-v36" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v35" in source
|
||||
assert "stackchain-dashboard-shell-v36" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -296,11 +296,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user