feat: snooze mobile deadline reminders (Closes #1102)
This commit is contained in:
parent
fdf6ae3ddd
commit
2bd20130b6
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v120';
|
const CACHE = 'stackchain-dashboard-shell-v121';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
@ -512,7 +512,7 @@ self.addEventListener('push', event => {
|
||||||
tag,
|
tag,
|
||||||
actions: [
|
actions: [
|
||||||
{ action: 'protect-today', title: 'Protect Today' },
|
{ action: 'protect-today', title: 'Protect Today' },
|
||||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
{ action: 'snooze-deadline', title: 'Remind in 1 hour' },
|
||||||
],
|
],
|
||||||
data: {route, protectRoute},
|
data: {route, protectRoute},
|
||||||
}
|
}
|
||||||
|
|
@ -630,10 +630,26 @@ self.addEventListener('notificationclick', event => {
|
||||||
event.waitUntil(openWorkRoute(protectRoute));
|
event.waitUntil(openWorkRoute(protectRoute));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.action === 'open-agenda') {
|
if (event.action === 'snooze-deadline') {
|
||||||
if (route !== '#/my-work/agenda') return;
|
if (
|
||||||
event.notification.close();
|
route !== '#/my-work/agenda'
|
||||||
event.waitUntil(openWorkRoute(route));
|
|| !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag)
|
||||||
|
) return;
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', {
|
||||||
|
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
||||||
|
});
|
||||||
|
event.notification.close();
|
||||||
|
} catch (_error) {
|
||||||
|
await openWorkRoute(route);
|
||||||
|
event.notification.close();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
})());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.action === 'tomorrow') {
|
if (event.action === 'tomorrow') {
|
||||||
|
|
|
||||||
17
src/main.py
17
src/main.py
|
|
@ -2236,6 +2236,23 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/push-subscription/deadlines/snooze")
|
||||||
|
async def snooze_deadline_reminder(request: Request):
|
||||||
|
device_id = await dashboard_auth.session_management_id(
|
||||||
|
request.state.dashboard_session
|
||||||
|
)
|
||||||
|
now = int(time.time())
|
||||||
|
snoozed = await asyncio.to_thread(
|
||||||
|
_push_subscription_store.snooze_deadline_reminder,
|
||||||
|
device_id,
|
||||||
|
now=now,
|
||||||
|
delay_seconds=3_600,
|
||||||
|
)
|
||||||
|
if not snoozed:
|
||||||
|
raise HTTPException(status_code=409, detail="Enable deadline reminders first")
|
||||||
|
return {"snoozed": True, "snoozed_until": now + 3_600}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/session/activity")
|
@app.post("/api/v1/session/activity")
|
||||||
async def record_session_activity(request: Request):
|
async def record_session_activity(request: Request):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
|
|
||||||
|
|
@ -348,10 +348,16 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||||
except ZoneInfoNotFoundError:
|
except ZoneInfoNotFoundError:
|
||||||
continue
|
continue
|
||||||
if (
|
snooze_due = (
|
||||||
local_now.hour >= device.reminder_hour
|
device.snoozed_until is not None
|
||||||
|
and device.snoozed_until <= current.timestamp()
|
||||||
|
)
|
||||||
|
daily_due = (
|
||||||
|
device.snoozed_until is None
|
||||||
|
and local_now.hour >= device.reminder_hour
|
||||||
and device.delivered_local_day != local_now.date().isoformat()
|
and device.delivered_local_day != local_now.date().isoformat()
|
||||||
):
|
)
|
||||||
|
if snooze_due or daily_due:
|
||||||
eligible_devices.append(device)
|
eligible_devices.append(device)
|
||||||
if not eligible_devices:
|
if not eligible_devices:
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -369,6 +375,11 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||||
continue
|
continue
|
||||||
due_days.append(due_day)
|
due_days.append(due_day)
|
||||||
if not due_days:
|
if not due_days:
|
||||||
|
await asyncio.gather(*(
|
||||||
|
asyncio.to_thread(store.clear_deadline_snooze, device.session_id)
|
||||||
|
for device in eligible_devices
|
||||||
|
if device.snoozed_until is not None
|
||||||
|
))
|
||||||
return 0
|
return 0
|
||||||
due_counts = {}
|
due_counts = {}
|
||||||
for device in eligible_devices:
|
for device in eligible_devices:
|
||||||
|
|
@ -377,6 +388,11 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||||
if due_count:
|
if due_count:
|
||||||
due_counts[device.session_id] = due_count
|
due_counts[device.session_id] = due_count
|
||||||
|
await asyncio.gather(*(
|
||||||
|
asyncio.to_thread(store.clear_deadline_snooze, device.session_id)
|
||||||
|
for device in eligible_devices
|
||||||
|
if device.snoozed_until is not None and device.session_id not in due_counts
|
||||||
|
))
|
||||||
eligible_devices = [
|
eligible_devices = [
|
||||||
device for device in eligible_devices if device.session_id in due_counts
|
device for device in eligible_devices if device.session_id in due_counts
|
||||||
]
|
]
|
||||||
|
|
@ -408,10 +424,16 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||||
except ZoneInfoNotFoundError:
|
except ZoneInfoNotFoundError:
|
||||||
return 0
|
return 0
|
||||||
local_day = local_now.date().isoformat()
|
local_day = local_now.date().isoformat()
|
||||||
if (
|
snooze_due = (
|
||||||
local_now.hour < device.reminder_hour
|
device.snoozed_until is not None
|
||||||
or device.delivered_local_day == local_day
|
and device.snoozed_until <= current.timestamp()
|
||||||
):
|
)
|
||||||
|
daily_due = (
|
||||||
|
device.snoozed_until is None
|
||||||
|
and local_now.hour >= device.reminder_hour
|
||||||
|
and device.delivered_local_day != local_day
|
||||||
|
)
|
||||||
|
if not (snooze_due or daily_due):
|
||||||
return 0
|
return 0
|
||||||
due_count = due_counts[device.session_id]
|
due_count = due_counts[device.session_id]
|
||||||
still_owner = await asyncio.to_thread(
|
still_owner = await asyncio.to_thread(
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class DeadlineReminderDevice:
|
||||||
reminder_hour: int
|
reminder_hour: int
|
||||||
reminder_days: int
|
reminder_days: int
|
||||||
delivered_local_day: str | None
|
delivered_local_day: str | None
|
||||||
|
snoozed_until: float | None
|
||||||
|
|
||||||
|
|
||||||
def _revisions(
|
def _revisions(
|
||||||
|
|
@ -94,6 +95,7 @@ class PushSubscriptionStore:
|
||||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||||
reminder_days INTEGER NOT NULL DEFAULT 2,
|
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||||
delivered_local_day TEXT,
|
delivered_local_day TEXT,
|
||||||
|
snoozed_until REAL,
|
||||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
@ -140,6 +142,10 @@ class PushSubscriptionStore:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||||
)
|
)
|
||||||
|
if "snoozed_until" not in preference_columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE push_deadline_preferences ADD COLUMN snoozed_until REAL"
|
||||||
|
)
|
||||||
def _connect(self):
|
def _connect(self):
|
||||||
connection = connect_private_sqlite(self.path, timeout=2)
|
connection = connect_private_sqlite(self.path, timeout=2)
|
||||||
connection.execute("PRAGMA foreign_keys = ON")
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
@ -233,20 +239,43 @@ class PushSubscriptionStore:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||||
p.reminder_hour, p.reminder_days, p.delivered_local_day
|
p.reminder_hour, p.reminder_days, p.delivered_local_day,
|
||||||
|
p.snoozed_until
|
||||||
FROM push_subscriptions s
|
FROM push_subscriptions s
|
||||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
|
DeadlineReminderDevice(
|
||||||
|
row[0], json.loads(row[1]), row[2], row[3], row[4], row[5], row[6]
|
||||||
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def snooze_deadline_reminder(
|
||||||
|
self, session_id: str, *, now: float, delay_seconds: int = 3_600
|
||||||
|
) -> bool:
|
||||||
|
with self._connect() as connection:
|
||||||
|
result = connection.execute(
|
||||||
|
"""UPDATE push_deadline_preferences SET snoozed_until = ?
|
||||||
|
WHERE session_id = ? AND enabled = 1""",
|
||||||
|
(now + delay_seconds, session_id),
|
||||||
|
)
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
def clear_deadline_snooze(self, session_id: str) -> None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE push_deadline_preferences SET snoozed_until = NULL
|
||||||
|
WHERE session_id = ?""",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
|
||||||
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
|
"""UPDATE push_deadline_preferences
|
||||||
|
SET delivered_local_day = ?, snoozed_until = NULL
|
||||||
WHERE session_id = ? AND enabled = 1""",
|
WHERE session_id = ? AND enabled = 1""",
|
||||||
(local_day, session_id),
|
(local_day, session_id),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v120" in worker
|
assert "stackchain-dashboard-shell-v121" in worker
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,110 @@ async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplic
|
||||||
assert "private/repo" not in json.dumps(sent)
|
assert "private/repo" not in json.dumps(sent)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_snoozed_deadline_revalidates_and_sends_once_after_expiry(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||||
|
)
|
||||||
|
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
|
||||||
|
snoozed_at = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
||||||
|
store.snooze_deadline_reminder(
|
||||||
|
"device-a", now=snoozed_at.timestamp(), delay_seconds=3_600
|
||||||
|
)
|
||||||
|
snapshots = 0
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def assigned():
|
||||||
|
nonlocal snapshots
|
||||||
|
snapshots += 1
|
||||||
|
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14"}]}
|
||||||
|
|
||||||
|
async def send(_subscription, payload):
|
||||||
|
sent.append(json.loads(payload))
|
||||||
|
|
||||||
|
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||||
|
assert await dispatch_deadline_reminders(
|
||||||
|
store, config, assigned, send,
|
||||||
|
now=datetime(2026, 8, 13, 10, 59, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert snapshots == 0
|
||||||
|
|
||||||
|
assert await dispatch_deadline_reminders(
|
||||||
|
store, config, assigned, send,
|
||||||
|
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
|
||||||
|
) == 1
|
||||||
|
assert snapshots == 1
|
||||||
|
assert sent[0]["deadline_count"] == 1
|
||||||
|
assert sent[0]["tag"] == "stackchain-deadline-digest-2026-08-13"
|
||||||
|
assert store.deadline_reminder_devices()[0].snoozed_until is None
|
||||||
|
assert await dispatch_deadline_reminders(
|
||||||
|
store, config, assigned, send,
|
||||||
|
now=datetime(2026, 8, 13, 11, 1, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert snapshots == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_expired_deadline_snooze_clears_when_no_deadlines_remain(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||||
|
)
|
||||||
|
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
|
||||||
|
store.snooze_deadline_reminder(
|
||||||
|
"device-a",
|
||||||
|
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def resolved():
|
||||||
|
return {"complete": True, "items": []}
|
||||||
|
|
||||||
|
assert await dispatch_deadline_reminders(
|
||||||
|
store,
|
||||||
|
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||||
|
resolved,
|
||||||
|
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert store.deadline_reminder_devices()[0].snoozed_until is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_expired_deadline_snooze_clears_when_deadlines_move_beyond_horizon(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
store.upsert("device-a", {
|
||||||
|
"endpoint": "https://push.example/device-a",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"device-a", enabled=True, timezone="UTC", reminder_hour=9, reminder_days=2
|
||||||
|
)
|
||||||
|
store.mark_deadline_reminder_delivered("device-a", "2026-08-13")
|
||||||
|
store.snooze_deadline_reminder(
|
||||||
|
"device-a",
|
||||||
|
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def replanned():
|
||||||
|
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-20"}]}
|
||||||
|
|
||||||
|
assert await dispatch_deadline_reminders(
|
||||||
|
store,
|
||||||
|
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||||
|
replanned,
|
||||||
|
now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert store.deadline_reminder_devices()[0].snoozed_until is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
|
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
|
@ -240,6 +344,35 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_snooze_is_device_bound_and_requires_enabled_reminders(tmp_path):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
for session_id in ("enabled", "disabled"):
|
||||||
|
store.upsert(session_id, {
|
||||||
|
"endpoint": f"https://push.example/{session_id}",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"enabled", enabled=True, timezone="UTC", reminder_hour=9
|
||||||
|
)
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
"disabled", enabled=False, timezone="UTC", reminder_hour=9
|
||||||
|
)
|
||||||
|
|
||||||
|
assert store.snooze_deadline_reminder(
|
||||||
|
"enabled", now=1_765_000_000, delay_seconds=3_600
|
||||||
|
) is True
|
||||||
|
assert store.snooze_deadline_reminder(
|
||||||
|
"disabled", now=1_765_000_000, delay_seconds=3_600
|
||||||
|
) is False
|
||||||
|
assert store.snooze_deadline_reminder(
|
||||||
|
"missing", now=1_765_000_000, delay_seconds=3_600
|
||||||
|
) is False
|
||||||
|
|
||||||
|
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
|
||||||
|
assert devices["enabled"].snoozed_until == 1_765_003_600
|
||||||
|
assert "disabled" not in devices
|
||||||
|
|
||||||
|
|
||||||
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||||
database = tmp_path / "push.sqlite3"
|
database = tmp_path / "push.sqlite3"
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
|
||||||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v120" in worker
|
assert "stackchain-dashboard-shell-v121" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
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 local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v120" in worker
|
assert "stackchain-dashboard-shell-v121" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "promptStorage:localStorage" in dashboard
|
assert "promptStorage:localStorage" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v120" in worker
|
assert "stackchain-dashboard-shell-v121" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in worker
|
assert "stackchain-dashboard-shell-v121" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v120" in service_worker
|
assert "stackchain-dashboard-shell-v121" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -1090,6 +1090,35 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk
|
||||||
assert store.deadline_preferences("session-a")["reminder_days"] == 7
|
assert store.deadline_preferences("session-a")["reminder_days"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_device_can_snooze_its_enabled_deadline_reminder(tmp_path, monkeypatch):
|
||||||
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
for session_id in ("session-a", "session-b"):
|
||||||
|
store.upsert(session_id, {
|
||||||
|
"endpoint": f"https://push.example/{session_id}",
|
||||||
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||||
|
})
|
||||||
|
store.set_deadline_preferences(
|
||||||
|
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||||
|
monkeypatch.setattr(main.time, "time", lambda: 1_765_000_000)
|
||||||
|
|
||||||
|
async def management_id(_session):
|
||||||
|
return "session-a"
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||||
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||||
|
|
||||||
|
assert await main.snooze_deadline_reminder(request) == {
|
||||||
|
"snoozed": True,
|
||||||
|
"snoozed_until": 1_765_003_600,
|
||||||
|
}
|
||||||
|
devices = {device.session_id: device for device in store.deadline_reminder_devices()}
|
||||||
|
assert devices["session-a"].snoozed_until == 1_765_003_600
|
||||||
|
assert devices["session-b"].snoozed_until is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
|
|
|
||||||
|
|
@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
|
||||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -183,7 +183,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||||
|
|
@ -192,7 +192,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.js'" in source
|
assert "BASE + 'static/issue-attachment.js'" in source
|
||||||
|
|
||||||
|
|
@ -200,14 +200,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -223,7 +223,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/issue-sheet.js'" in source
|
assert "BASE + 'static/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
@ -233,14 +233,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -249,21 +249,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -850,7 +850,7 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
||||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_private_copy():
|
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
await dispatchPush({
|
await dispatchPush({
|
||||||
|
|
@ -870,7 +870,7 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_
|
||||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||||
"actions": [
|
"actions": [
|
||||||
{"action": "protect-today", "title": "Protect Today"},
|
{"action": "protect-today", "title": "Protect Today"},
|
||||||
{"action": "open-agenda", "title": "Open Agenda"},
|
{"action": "snooze-deadline", "title": "Remind in 1 hour"},
|
||||||
],
|
],
|
||||||
"data": {
|
"data": {
|
||||||
"route": "#/my-work/agenda",
|
"route": "#/my-work/agenda",
|
||||||
|
|
@ -884,19 +884,62 @@ def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_
|
||||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||||
|
|
||||||
|
|
||||||
def test_deadline_digest_open_agenda_action_preserves_browsing_flow():
|
def test_deadline_digest_snooze_action_records_device_bound_reminder_without_opening_app():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
await dispatchPush({
|
await dispatchPush({
|
||||||
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda',
|
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda',
|
||||||
protect_route:'#/my-work/agenda/protect-today', deadline_count:1,
|
protect_route:'#/my-work/agenda/protect-today', deadline_count:1,
|
||||||
});
|
});
|
||||||
await dispatchNotificationClick('#/my-work/agenda', 'open-agenda', null, 'stackchain-deadline-digest-2026-08-13');
|
const calls = [];
|
||||||
|
context.fetch = async (request, options = {}) => {
|
||||||
|
const url = String(request.url || request);
|
||||||
|
const headers = new Headers(options.headers || {});
|
||||||
|
calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')});
|
||||||
|
if (url.endsWith('/api/v1/session')) {
|
||||||
|
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
|
||||||
|
status:200, headers:{'Content-Type':'application/json'},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({snoozed:true}), {
|
||||||
|
status:200, headers:{'Content-Type':'application/json'},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await dispatchNotificationClick('#/my-work/agenda', 'snooze-deadline', null, 'stackchain-deadline-digest-2026-08-13');
|
||||||
|
process.stdout.write(JSON.stringify({state,calls}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["calls"] == [
|
||||||
|
{
|
||||||
|
"url": "https://forge.example/dashboard/api/v1/session",
|
||||||
|
"method": "GET",
|
||||||
|
"csrf": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://forge.example/dashboard/api/v1/push-subscription/deadlines/snooze",
|
||||||
|
"method": "PATCH",
|
||||||
|
"csrf": "session-proof",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
assert result["state"]["notificationClosed"] is True
|
||||||
|
assert result["state"]["opened"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_digest_failed_snooze_opens_agenda_instead_of_losing_reminder():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
context.fetch = async () => { throw new Error('offline'); };
|
||||||
|
await dispatchNotificationClick(
|
||||||
|
'#/my-work/agenda', 'snooze-deadline', null,
|
||||||
|
'stackchain-deadline-digest-2026-08-13'
|
||||||
|
);
|
||||||
process.stdout.write(JSON.stringify(state));
|
process.stdout.write(JSON.stringify(state));
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"]
|
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"]
|
||||||
|
assert result["notificationClosed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
||||||
|
|
@ -1094,7 +1137,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v120';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v121';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v120" in source
|
assert "stackchain-dashboard-shell-v121" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user