Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51278bc86f | ||
|
|
a919242b8f | ||
|
|
56a9a0cba6 | ||
|
|
27fc74ced6 | ||
|
|
f686e2e71e |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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 = [
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"},
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user