Compare commits

...
Author SHA1 Message Date
rockachopa 51278bc86f Merge pull request 'Bundle and fingerprint the mobile PWA runtime' (#380) from timmy/379-content-hashed-pwa-shell into main
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 07:00:36 +00:00
timmy a919242b8f perf: bundle and fingerprint the PWA runtime (#379)
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:59:05 +00:00
rockachopa 56a9a0cba6 Merge pull request 'Keep Later plans conflict-safe across devices' (#378) from timmy/377-later-conflict-safe into main
CI / lint (push) Successful in 38s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 06:39:03 +00:00
timmy 27fc74ced6 fix: keep Later plans conflict-safe across devices (#377)
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:37:30 +00:00
rockachopa f686e2e71e Merge pull request 'Batch queued Today and Later planning edits' (#376)
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #375
2026-08-09 06:07:19 +00:00
18 changed files with 384 additions and 83 deletions

No files matched your search

+9 -3
View File
@@ -160,7 +160,10 @@ Every response also defines the browser execution boundary with a Content Securi
Policy that allows scripts only from the dashboard origin, denies framing and
plugins, and blocks unused browser capabilities. Keep these response headers when
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
and stylesheet are same-origin static assets included in the offline PWA shell.
is assembled in source order into one content-addressed JavaScript response. Dashboard
HTML and the offline worker reference that exact fingerprint, while the runtime receives
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
@@ -224,8 +227,11 @@ or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later wake times are scoped to the confirmed Gitea login and synchronize
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
and replay after reconnect; `STACKCHAIN_LATER_DB` can override the default durable
store at `.stackchain-state/later.sqlite3`.
and replay after reconnect. Each queued edit carries the account revision it was based
on; if another device has since changed the same item, Stackchain keeps the newer
account plan and reports the conflict instead of silently overwriting it. Unrelated
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
default durable store at `.stackchain-state/later.sqlite3`.
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage or a dashboard HTTP
+4 -3
View File
@@ -186,9 +186,10 @@
},
onStatus: (state, detail = {}) => {
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Later saved on this device · sync pending.' :
'Later sync unavailable · changes stay on this device.'));
(state === 'conflict' ? `Another device changed this Later item${detail.count > 1 ? 's' : ''} · account plan kept.` :
(state === 'retrying' ? `Later saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
(state === 'pending' ? 'Later saved on this device · sync pending.' :
'Later sync unavailable · changes stay on this device.')));
},
});
laterSync.startLifecycle({ window, document });
+14 -3
View File
@@ -115,7 +115,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
knownOperationKeys.delete(record.recordKey);
});
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
.map(record => record.operation).filter(operation =>
.map(record => ({
...record.operation,
base_revision: Number.isInteger(record.operation.base_revision)
? record.operation.base_revision : 0,
})).filter(operation =>
operation && typeof operation.operation_id === 'string' &&
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
@@ -148,7 +152,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
(action === 'defer' && typeof wakeAt !== 'string')) return false;
pending().filter(operation => operation.item_id === itemId)
.forEach(operation => removeOperation(operation.operation_id));
const operation = { operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt };
const operation = {
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
@@ -186,6 +193,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
try {
let operations = pending();
let plan;
let conflictCount = 0;
if (!operations.length) plan = await fetchJson('api/v1/later');
while (operations.length) {
if (key() !== ownerKey) return false;
@@ -202,6 +210,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
...(plan.duplicate_operation_ids || []),
...(plan.rejected_operations || []).map(item => item.operation_id),
] : batch.map(item => item.operation_id);
conflictCount += (plan.rejected_operations || [])
.filter(item => item.reason === 'stale_intent').length;
for (const operationId of received) {
if (pending().some(candidate => candidate.operation_id === operationId) &&
!removeOperation(operationId)) {
@@ -211,7 +221,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : 'saved');
onStatus?.(pending().length ? 'pending' :
(conflictCount ? 'conflict' : 'saved'), conflictCount ? { count: conflictCount } : {});
retryAttempt = 0;
cancelRetry();
return true;
+1 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v54';
const CACHE = 'stackchain-dashboard-shell-v55';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
+70
View File
@@ -0,0 +1,70 @@
"""Build the content-addressed browser shell served by the dashboard."""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
@dataclass(frozen=True)
class FrontendBuild:
dashboard_html: str
runtime_bytes: bytes
runtime_digest: str
runtime_name: str
page_sources: tuple[str, ...]
service_worker_source: str
def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
chunks = []
for source in sources:
path = frontend_dir / source.removeprefix("static/")
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
return b"".join(chunks)
def build_frontend(frontend_dir: Path) -> FrontendBuild:
"""Return one internally consistent HTML, runtime, and offline-worker build."""
source_html = (frontend_dir / "index.html").read_text()
sources = tuple(SCRIPT_TAG.findall(source_html))
if not sources:
raise ValueError("dashboard entry document has no page scripts")
runtime = _bundle(frontend_dir, sources)
digest = hashlib.sha256(runtime).hexdigest()[:16]
runtime_name = f"runtime-{digest}.js"
dashboard_html = SCRIPT_TAG.sub("", source_html)
dashboard_html = dashboard_html.replace(
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
)
worker = (frontend_dir / "service-worker.js").read_text()
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
)
worker = re.sub(
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
worker,
count=1,
)
return FrontendBuild(
dashboard_html=dashboard_html,
runtime_bytes=runtime,
runtime_digest=digest,
runtime_name=runtime_name,
page_sources=sources,
service_worker_source=worker,
)
+57 -48
View File
@@ -33,6 +33,16 @@ class LaterStore:
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS later_item_revisions (
login TEXT NOT NULL,
item_id TEXT NOT NULL,
revision INTEGER NOT NULL,
PRIMARY KEY (login, item_id)
)
"""
)
return connection
@staticmethod
@@ -74,54 +84,16 @@ class LaterStore:
item_id: str,
*,
wake_at: str | None = None,
base_revision: int | None = None,
) -> dict:
login = self._normalize_login(login)
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"defer", "restore"}:
raise ValueError("unsupported Later action")
if action == "defer":
wake_at = self._validate_wake_at(wake_at)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
duplicate = connection.execute(
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
return snapshot
records = dict(snapshot["records"])
before = records.get(item_id)
if action == "defer":
records[item_id] = wake_at
changed = before != wake_at
else:
changed = item_id in records
records.pop(item_id, None)
revision = snapshot["revision"] + (1 if changed else 0)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
if row is None:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif changed:
connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login),
)
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
return {"revision": revision, "records": records}
result = self.apply_batch(login, [{
"operation_id": operation_id,
"action": action,
"item_id": item_id,
"wake_at": wake_at,
"base_revision": base_revision,
}])
return {"revision": result["revision"], "records": result["records"]}
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply ordered deferrals with one SQLite write transaction."""
@@ -136,6 +108,20 @@ class LaterStore:
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
rejected: list[dict[str, str]] = []
# Existing databases predate per-item revisions. Conservatively mark
# active deferrals as changed at the latest known plan revision.
for item_id in records:
connection.execute(
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login, item_id, revision),
)
item_revisions = dict(connection.execute(
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
(login,),
).fetchall())
batch_start_item_revisions = dict(item_revisions)
for operation in operations:
operation_id = operation.get("operation_id", "")
@@ -156,6 +142,22 @@ class LaterStore:
duplicates.append(operation_id)
continue
base_revision = operation.get("base_revision")
if base_revision is None:
base_revision = snapshot["revision"]
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
raise ValueError("base_revision must be a non-negative integer")
if batch_start_item_revisions.get(item_id, 0) > base_revision:
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
rejected.append({
"operation_id": operation_id,
"reason": "stale_intent",
})
continue
before = records.get(item_id)
if action == "defer":
records[item_id] = wake_at
@@ -164,6 +166,13 @@ class LaterStore:
changed = item_id in records
records.pop(item_id, None)
revision += 1 if changed else 0
if changed:
item_revisions[item_id] = revision
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
(login, item_id, revision),
)
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
@@ -186,5 +195,5 @@ class LaterStore:
"records": records,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": [],
"rejected_operations": rejected,
}
+10 -2
View File
@@ -37,7 +37,7 @@ from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodayStore
from src.views import router as frontend_router
from src.views import FRONTEND_BUILD, router as frontend_router
@asynccontextmanager
async def lifespan(_app: FastAPI):
@@ -222,6 +222,7 @@ class LaterOperation(BaseModel):
action: Literal["defer", "restore"]
item_id: str = Field(min_length=1, max_length=500)
wake_at: str | None = Field(default=None, max_length=100)
base_revision: int | None = Field(default=None, ge=0)
class TodayOperationBatch(BaseModel):
@@ -587,7 +588,13 @@ async def require_operator_session(request: Request, call_next):
return await call_next(request)
public = (
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
path in {
"/healthz",
"/readyz",
"/login",
"/manifest.webmanifest",
"/" + FRONTEND_BUILD.runtime_name,
}
or path.startswith("/static/")
or (path == "/api/v1/session" and request.method == "POST")
)
@@ -969,6 +976,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
payload.action,
payload.item_id,
wake_at=payload.wake_at,
base_revision=payload.base_revision,
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
+21 -8
View File
@@ -1,12 +1,14 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse, HTMLResponse
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, Response
from src.frontend_bundle import build_frontend
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
FRONTEND_BUILD = build_frontend(DASHBOARD_FILE.parent)
LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -26,7 +28,7 @@ class RevalidatingHTMLResponse(HTMLResponse):
@router.get("/", response_class=RevalidatingHTMLResponse)
async def dashboard() -> str:
return DASHBOARD_FILE.read_text()
return FRONTEND_BUILD.dashboard_html
@router.get("/login", response_class=RevalidatingHTMLResponse)
@@ -39,10 +41,21 @@ async def web_app_manifest() -> FileResponse:
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
@router.get("/service-worker.js", response_class=FileResponse)
async def service_worker() -> FileResponse:
return FileResponse(
SERVICE_WORKER_FILE,
@router.get("/service-worker.js")
async def service_worker() -> Response:
return Response(
FRONTEND_BUILD.service_worker_source,
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@router.get("/runtime-{digest}.js")
async def runtime_bundle(digest: str) -> Response:
if digest != FRONTEND_BUILD.runtime_digest:
raise HTTPException(status_code=404, detail="Runtime revision not found")
return Response(
FRONTEND_BUILD.runtime_bytes,
media_type="application/javascript",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
+10 -1
View File
@@ -1,9 +1,17 @@
import re
from pathlib import Path
from src.views import dashboard as dashboard_html
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
PAGE_SCRIPT_MANIFEST = "\n".join(
re.findall(
r'^<script src="static/[^\"]+\.js"></script>$',
(FRONTEND / "index.html").read_text(),
re.MULTILINE,
)
)
def dashboard_bundle_text() -> str:
@@ -18,11 +26,12 @@ def dashboard_bundle_text() -> str:
async def dashboard() -> str:
# Exercise the real view lookup before adding its separately served assets.
# Exercise the real view lookup before adding its separately served bootstrap assets.
html = await dashboard_html()
return "\n".join(
(
html,
PAGE_SCRIPT_MANIFEST,
(FRONTEND / "dashboard.css").read_text(),
(FRONTEND / "dashboard.js").read_text(),
)
+9 -1
View File
@@ -7,6 +7,7 @@ import pytest
from src import main
from src.session_store import SessionStoreError
from src.views import FRONTEND_BUILD
@pytest.fixture
@@ -956,9 +957,16 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
login = await client.get("/login")
manifest = await client.get("/manifest.webmanifest")
static = await client.get("/static/session.js")
runtime = await client.get("/" + FRONTEND_BUILD.runtime_name)
assert signed_in.status_code == 200
assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200]
assert [login.status_code, manifest.status_code, static.status_code, runtime.status_code] == [
200,
200,
200,
200,
]
assert runtime.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
+62
View File
@@ -0,0 +1,62 @@
import gzip
import re
import shutil
from pathlib import Path
import pytest
from src.frontend_bundle import build_frontend
from src.views import dashboard, runtime_bundle, service_worker
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
first = build_frontend(FRONTEND)
second = build_frontend(FRONTEND)
assert first.runtime_name == second.runtime_name
assert first.runtime_bytes == second.runtime_bytes
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
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 = build_frontend(changed_frontend)
assert changed.runtime_name != first.runtime_name
assert changed.runtime_bytes != first.runtime_bytes
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
build = build_frontend(FRONTEND)
assert f"BASE + '{build.runtime_name}'" in build.service_worker_source
for source in build.page_sources:
if source == "static/background-issue-sync.js":
continue
assert f"BASE + '{source}'" not in build.service_worker_source
@pytest.mark.anyio
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
build = build_frontend(FRONTEND)
html = await dashboard()
bundle = await runtime_bundle(build.runtime_digest)
worker = await service_worker()
assert build.runtime_name in html
assert bundle.body == build.runtime_bytes
assert bundle.headers["cache-control"] == "public, max-age=31536000, immutable"
assert worker.headers["cache-control"] == "no-cache"
assert build.runtime_name.encode() in worker.body
+70
View File
@@ -86,6 +86,57 @@ def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
assert replay["duplicate_operation_ids"] == ["first", "second"]
def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
first = store.apply_batch("timmy", [{
"operation_id": "newer-device",
"action": "defer",
"item_id": "issue:r:1:",
"wake_at": "2026-08-12T09:00:00.000Z",
"base_revision": 0,
}])
replay = store.apply_batch("timmy", [
{
"operation_id": "stale-device",
"action": "defer",
"item_id": "issue:r:1:",
"wake_at": "2026-08-10T09:00:00.000Z",
"base_revision": 0,
},
{
"operation_id": "unrelated-item",
"action": "defer",
"item_id": "issue:r:2:",
"wake_at": "2026-08-11T09:00:00.000Z",
"base_revision": 0,
},
])
assert first["revision"] == 1
assert replay == {
"revision": 2,
"records": {
"issue:r:1:": "2026-08-12T09:00:00.000Z",
"issue:r:2:": "2026-08-11T09:00:00.000Z",
},
"accepted_operation_ids": ["unrelated-item"],
"duplicate_operation_ids": [],
"rejected_operations": [
{"operation_id": "stale-device", "reason": "stale_intent"}
],
}
duplicate = store.apply_batch("timmy", [{
"operation_id": "stale-device",
"action": "restore",
"item_id": "issue:r:2:",
"base_revision": 2,
}])
assert duplicate["duplicate_operation_ids"] == ["stale-device"]
assert duplicate["revision"] == 2
assert duplicate["records"] == replay["records"]
@pytest.mark.anyio
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@@ -129,6 +180,21 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
stale = await client.patch(
"/api/v1/later",
json={
"operations": [{
"operation_id": "offline-stale",
"action": "restore",
"item_id": "issue:stackchain/dashboard:363:",
"base_revision": 0,
}],
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
fetched = await client.get("/api/v1/later")
assert forbidden.status_code == 403
@@ -143,6 +209,10 @@ async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypat
"duplicate_operation_ids": [],
"rejected_operations": [],
}
assert stale.json()["rejected_operations"] == [
{"operation_id": "offline-stale", "reason": "stale_intent"}
]
assert stale.json()["revision"] == 2
assert fetched.json() == {"revision": 2, "records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
+35 -1
View File
@@ -90,12 +90,44 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
"action": "defer",
"item_id": "issue:r:2:",
"wake_at": "2026-08-10T09:00:00.000Z",
"base_revision": 0,
}]}
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
assert result["status"] == "saved"
assert result["pending"] == []
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const requests=[];const statuses=[];const adopted=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const sync=createLaterSync({{
storage,getLogin:()=> 'timmy',createOperationId:()=> 'offline-old',
fetchJson:async(_url,options={{}})=>{{
if(!options.method)return {{revision:3,records:{{'issue:r:1:':'2026-08-12T09:00:00.000Z'}}}};
const body=JSON.parse(options.body);requests.push(body);
return {{revision:4,records:{{'issue:r:1:':'2026-08-13T09:00:00.000Z'}},accepted_operation_ids:[],duplicate_operation_ids:[],rejected_operations:[{{operation_id:'offline-old',reason:'stale_intent'}}]}};
}},onRemoteRecords:records=>adopted.push(records),onStatus:(state,detail={{}})=>statuses.push([state,detail]),
}});
(async()=>{{await sync.flush();sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');await sync.flush();
process.stdout.write(JSON.stringify({{requests,statuses,adopted,pending:sync.pending()}}));}})();
"""
result = run_node(script)
assert result["requests"] == [{"operations": [{
"operation_id": "offline-old",
"action": "defer",
"item_id": "issue:r:1:",
"wake_at": "2026-08-10T09:00:00.000Z",
"base_revision": 3,
}]}]
assert result["statuses"][-1] == ["conflict", {"count": 1}]
assert result["adopted"][-1] == {
"issue:r:1:": "2026-08-13T09:00:00.000Z"
}
assert result["pending"] == []
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
@@ -136,6 +168,7 @@ sync.enqueue('restore','issue:r:2:');
"action": "restore",
"item_id": "issue:r:2:",
"wake_at": None,
"base_revision": 0,
}
],
"status": "retrying",
@@ -283,10 +316,11 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
assert "laterSync.migrate(laterWork.read());" in html
assert "laterSync.flush();" in html
assert "Later saved to account." in html
assert "Another device changed this Later item" in html
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "stackchain-dashboard-shell-v55" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v54" in worker
assert "stackchain-dashboard-shell-v55" in worker
+1 -1
View File
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
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-v54" in worker
assert "stackchain-dashboard-shell-v55" in worker
+6 -6
View File
@@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "stackchain-dashboard-shell-v55" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "stackchain-dashboard-shell-v55" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "stackchain-dashboard-shell-v55" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -132,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "stackchain-dashboard-shell-v55" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "stackchain-dashboard-shell-v55" 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-v54" in source
assert "stackchain-dashboard-shell-v55" in source
assert "BASE + 'static/update-ownership.js'" in source
+1 -1
View File
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "stackchain-dashboard-shell-v55" in source
assert "BASE + 'static/today-sync.js'" in source
+3 -3
View File
@@ -2,7 +2,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from src.views import FRONTEND_BUILD, dashboard
@pytest.mark.anyio
@@ -28,7 +28,7 @@ async def test_dashboard_bootstrap_uses_csp_compatible_static_assets():
html = await dashboard()
assert '<link rel="stylesheet" href="static/dashboard.css" />' in html
assert '<script src="static/dashboard.js"></script>' in html
assert f'<script src="{FRONTEND_BUILD.runtime_name}"></script>' in html
assert "<style>" not in html
assert "<script>" not in html
@@ -42,7 +42,7 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
assert 'aria-labelledby="search-preview-title"' in html
assert 'id="claim-search-result"' in html
assert 'id="open-search-result-gitea"' in html
assert 'src="static/search-preview.js"' in html
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
assert ".search-preview-panel" in css
assert "height:100dvh" in css
assert "env(safe-area-inset-bottom)" in css