feat: guide first mobile task activation (Closes #1140)
This commit is contained in:
1 parent
8731682090
commit
304e4a8a24
11 files changed
+343
-1
No files matched your search
@@ -16,6 +16,61 @@ from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(390, 844)])
|
||||
def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
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))
|
||||
fake.assigned_issue_numbers = []
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("First task release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
sheet = page.locator("#mobile-first-task")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_focused()
|
||||
for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#mobile-first-task-find").click()
|
||||
expect(sheet).to_be_hidden()
|
||||
expect(page.locator("#find-work-sheet")).to_be_visible()
|
||||
page.locator("#close-find-work").click()
|
||||
|
||||
context.set_offline(True)
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_disabled()
|
||||
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
tmp_path: Path, width: int, height: int
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-first-task.js"
|
||||
|
||||
|
||||
def run_node(body: str) -> dict:
|
||||
script = f"""
|
||||
const createFirstTask = require({json.dumps(str(CONTROLLER))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners = {{}}; this.disabled = false; this.hidden = false; this.open = false; this.textContent = ''; this.focused = false; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
|
||||
showModal() {{ this.open = true; }}
|
||||
close() {{ this.open = false; this.listeners.close?.(); }}
|
||||
focus() {{ this.focused = true; }}
|
||||
}}
|
||||
{body}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_first_task_activation_is_account_bound_and_completes_when_work_appears():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
|
||||
const sheet = new Element();
|
||||
let login = 'Timmy';
|
||||
let hasWork = false;
|
||||
const controller = createFirstTask({
|
||||
storage, getLogin:() => login, hasWork:() => hasWork, isOnline:() => true,
|
||||
mediaQuery:{matches:true}, sheet, findButton:new Element(), createButton:new Element(),
|
||||
setupButton:new Element(), closeButton:new Element(), status:new Element(),
|
||||
onFind() {}, onCreate() {}, onSetup() {},
|
||||
});
|
||||
const before = [controller.required(), controller.open(), sheet.open];
|
||||
hasWork = true;
|
||||
const completed = controller.refresh();
|
||||
const timmyRequired = controller.required();
|
||||
login = 'alexander'; hasWork = false;
|
||||
const alexanderRequired = controller.required();
|
||||
process.stdout.write(JSON.stringify({before, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": [True, True, True],
|
||||
"completed": "completed",
|
||||
"sheetOpen": False,
|
||||
"timmyRequired": False,
|
||||
"alexanderRequired": True,
|
||||
"values": [["stackchain.first-task.v1:timmy", "complete"]],
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
||||
result = run_node(
|
||||
"""
|
||||
const calls = [];
|
||||
const sheet = new Element();
|
||||
const findButton = new Element();
|
||||
const createButton = new Element();
|
||||
const setupButton = new Element();
|
||||
const closeButton = new Element();
|
||||
const status = new Element();
|
||||
let online = false;
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => false,
|
||||
isOnline:() => online, mediaQuery:{matches:true}, sheet, findButton, createButton,
|
||||
setupButton, closeButton, status,
|
||||
onFind:() => calls.push('find'), onCreate:() => calls.push('create'), onSetup:() => calls.push('setup'),
|
||||
});
|
||||
controller.start();
|
||||
controller.open();
|
||||
const offline = {findDisabled:findButton.disabled, createDisabled:createButton.disabled, status:status.textContent, createFocused:createButton.focused};
|
||||
findButton.click();
|
||||
createButton.click();
|
||||
controller.open();
|
||||
setupButton.click();
|
||||
controller.open();
|
||||
online = true;
|
||||
controller.render();
|
||||
findButton.click();
|
||||
process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"offline": {
|
||||
"findDisabled": True,
|
||||
"createDisabled": False,
|
||||
"status": "You are offline. Create a task now and it will stay in Drafts until you reconnect.",
|
||||
"createFocused": True,
|
||||
},
|
||||
"calls": ["create", "setup", "find"],
|
||||
"sheetOpen": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">' in html
|
||||
assert '<h2 id="mobile-first-task-title">Start your first task</h2>' in html
|
||||
assert 'id="mobile-first-task-find" type="button">Find a task</button>' in html
|
||||
assert 'id="mobile-first-task-create" type="button">Create a task</button>' in html
|
||||
assert 'id="mobile-first-task-setup" type="button">Make this phone work-ready</button>' in html
|
||||
assert '<script src="static/mobile-first-task.js"></script>' in html
|
||||
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
||||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||
assert "mobileFirstTask.refresh()" in html
|
||||
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-first-task-actions button { min-height:48px;' in html
|
||||
@@ -140,6 +140,35 @@ process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
|
||||
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
|
||||
|
||||
|
||||
def test_mobile_work_entry_guides_an_empty_first_workspace_before_find():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
let activationRequired = true;
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => false,
|
||||
isTodayResumable: () => false,
|
||||
getTodayCount: () => 0,
|
||||
getEligibleCount: () => 0,
|
||||
shouldActivate: () => activationRequired,
|
||||
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
|
||||
openActivation: () => calls.push('activate'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [entry.open()];
|
||||
activationRequired = false;
|
||||
modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["activate", "find"],
|
||||
"calls": ["activate", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
|
||||
@@ -1257,6 +1257,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/live-data-status.js",
|
||||
"/dashboard/static/mobile-today-command-bar.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-first-task.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-delivery-recovery.js",
|
||||
|
||||
Reference in new issue
Block a user