Lazy-load the mobile issue capture workflow #538
|
|
@ -366,7 +366,7 @@
|
||||||
loadLabels: item => issueController.loadLabels(item),
|
loadLabels: item => issueController.loadLabels(item),
|
||||||
loadMilestones: item => issueController.loadMilestones(item),
|
loadMilestones: item => issueController.loadMilestones(item),
|
||||||
});
|
});
|
||||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
let issueCapture = null;
|
||||||
const unfiledCaptures = createUnfiledCaptures({
|
const unfiledCaptures = createUnfiledCaptures({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||||
|
|
@ -416,8 +416,28 @@
|
||||||
text: shareParams.get('text') || '',
|
text: shareParams.get('text') || '',
|
||||||
url: shareParams.get('url') || '',
|
url: shareParams.get('url') || '',
|
||||||
};
|
};
|
||||||
let sharedLaunchState = Object.values(sharedLaunch).some(Boolean) ?
|
const issueCaptureFeatures = createFeatureLoader({
|
||||||
issueCapture.stageSharedContent(sharedLaunch) : null;
|
document,
|
||||||
|
urls: {
|
||||||
|
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let sharedLaunchState = null;
|
||||||
|
let sharedLaunchHandled = false;
|
||||||
|
async function ensureIssueCapture() {
|
||||||
|
return await issueCaptureFeatures.run('issue-capture', {
|
||||||
|
trigger: qs('#new-issue'), status: qs('#my-work-action-status'),
|
||||||
|
}, () => {
|
||||||
|
if (!issueCapture) {
|
||||||
|
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
|
}
|
||||||
|
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||||
|
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
||||||
|
sharedLaunchHandled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture();
|
||||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||||
|
|
@ -2759,6 +2779,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveIssueCaptureDraft() {
|
function saveIssueCaptureDraft() {
|
||||||
|
if (!issueCapture) return;
|
||||||
issueCapture.saveDraft({
|
issueCapture.saveDraft({
|
||||||
repository: qs('#create-issue-repository').value,
|
repository: qs('#create-issue-repository').value,
|
||||||
title: qs('#create-issue-title').value,
|
title: qs('#create-issue-title').value,
|
||||||
|
|
@ -2955,7 +2976,8 @@
|
||||||
sharedLaunchState = null;
|
sharedLaunchState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreateIssueSheet(navigate = true) {
|
async function openCreateIssueSheet(navigate = true) {
|
||||||
|
if (!issueCapture && !await ensureIssueCapture()) return;
|
||||||
if (navigate) {
|
if (navigate) {
|
||||||
taskOverlayHistory.open('new');
|
taskOverlayHistory.open('new');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
60
frontend/feature-loader.js
Normal file
60
frontend/feature-loader.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||||
|
const pending = new Map();
|
||||||
|
const loaded = new Set();
|
||||||
|
|
||||||
|
function load(name) {
|
||||||
|
if (loaded.has(name)) return Promise.resolve();
|
||||||
|
if (pending.has(name)) return pending.get(name);
|
||||||
|
const url = urls[name];
|
||||||
|
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
|
||||||
|
|
||||||
|
const request = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
let timeout;
|
||||||
|
const fail = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
script.remove();
|
||||||
|
pending.delete(name);
|
||||||
|
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
|
||||||
|
};
|
||||||
|
script.src = url;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
pending.delete(name);
|
||||||
|
loaded.add(name);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = fail;
|
||||||
|
timeout = setTimeout(fail, timeoutMs);
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
pending.set(name, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(name, elements, callback) {
|
||||||
|
const trigger = elements?.trigger;
|
||||||
|
const status = elements?.status;
|
||||||
|
if (trigger) trigger.disabled = true;
|
||||||
|
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
|
||||||
|
try {
|
||||||
|
await load(name);
|
||||||
|
callback();
|
||||||
|
if (status) status.textContent = '';
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
if (status) {
|
||||||
|
const label = name === 'issue-capture' ? 'Issue capture' : name.replace(/-/g, ' ');
|
||||||
|
status.textContent = label + ' could not load. Tap New issue to retry.';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (trigger) trigger.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, run, ready: name => loaded.has(name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
|
||||||
|
|
@ -718,6 +718,7 @@
|
||||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||||
|
|
||||||
<script src="static/session.js"></script>
|
<script src="static/session.js"></script>
|
||||||
|
<script src="static/feature-loader.js"></script>
|
||||||
<script src="static/markdown.js"></script>
|
<script src="static/markdown.js"></script>
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const SHELL = [
|
||||||
BASE + 'static/icons/stackchain-192.png',
|
BASE + 'static/icons/stackchain-192.png',
|
||||||
BASE + 'static/icons/stackchain-512.png',
|
BASE + 'static/icons/stackchain-512.png',
|
||||||
BASE + 'static/session.js',
|
BASE + 'static/session.js',
|
||||||
|
BASE + 'static/feature-loader.js',
|
||||||
BASE + 'static/markdown.js',
|
BASE + 'static/markdown.js',
|
||||||
BASE + 'static/commands.js',
|
BASE + 'static/commands.js',
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,20 @@ import rjsmin
|
||||||
|
|
||||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||||
|
FEATURE_SOURCES = {"issue-capture": ("static/create-issue-sheet.js",)}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeBundle:
|
||||||
|
runtime_bytes: bytes
|
||||||
|
runtime_gzip_bytes: bytes
|
||||||
|
runtime_digest: str
|
||||||
|
runtime_name: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FrontendBuild:
|
class FrontendBuild:
|
||||||
dashboard_html: str
|
dashboard_html: str
|
||||||
|
|
@ -27,6 +36,7 @@ class FrontendBuild:
|
||||||
runtime_name: str
|
runtime_name: str
|
||||||
shell_digest: str
|
shell_digest: str
|
||||||
page_sources: tuple[str, ...]
|
page_sources: tuple[str, ...]
|
||||||
|
feature_bundles: dict[str, RuntimeBundle]
|
||||||
service_worker_source: str
|
service_worker_source: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,6 +51,17 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||||
return minified + f';"source-sha256:{revision}";'.encode()
|
return minified + f';"source-sha256:{revision}";'.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
|
||||||
|
content = _bundle(frontend_dir, sources)
|
||||||
|
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||||
|
return RuntimeBundle(
|
||||||
|
runtime_bytes=content,
|
||||||
|
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||||
|
runtime_digest=digest,
|
||||||
|
runtime_name=f"{prefix}-{digest}.js",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
||||||
source_html = (frontend_dir / "index.html").read_text()
|
source_html = (frontend_dir / "index.html").read_text()
|
||||||
|
|
@ -48,13 +69,21 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
if not sources:
|
if not sources:
|
||||||
raise ValueError("dashboard entry document has no page scripts")
|
raise ValueError("dashboard entry document has no page scripts")
|
||||||
|
|
||||||
runtime = _bundle(frontend_dir, sources)
|
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
|
||||||
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
|
core_sources = tuple(source for source in sources if source not in feature_source_names)
|
||||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
core = _runtime(frontend_dir, core_sources, "runtime")
|
||||||
runtime_name = f"runtime-{digest}.js"
|
feature_bundles = {
|
||||||
|
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
|
||||||
|
for name, feature_sources in FEATURE_SOURCES.items()
|
||||||
|
}
|
||||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||||
|
feature_metadata = "\n".join(
|
||||||
|
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||||
|
for name, bundle in feature_bundles.items()
|
||||||
|
)
|
||||||
|
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||||
dashboard_html = dashboard_html.replace(
|
dashboard_html = dashboard_html.replace(
|
||||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
|
||||||
)
|
)
|
||||||
|
|
||||||
worker = (frontend_dir / "service-worker.js").read_text()
|
worker = (frontend_dir / "service-worker.js").read_text()
|
||||||
|
|
@ -63,7 +92,9 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||||
worker = worker.replace(
|
worker = worker.replace(
|
||||||
" BASE + 'static/dashboard.css',\n",
|
" BASE + 'static/dashboard.css',\n",
|
||||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
" BASE + 'static/dashboard.css',\n"
|
||||||
|
+ f" BASE + '{core.runtime_name}',\n"
|
||||||
|
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
|
||||||
)
|
)
|
||||||
worker = CACHE_DECLARATION.sub(
|
worker = CACHE_DECLARATION.sub(
|
||||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||||
|
|
@ -72,7 +103,8 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
shell_hasher = hashlib.sha256()
|
shell_hasher = hashlib.sha256()
|
||||||
shell_inputs = (
|
shell_inputs = (
|
||||||
("dashboard.html", dashboard_html.encode()),
|
("dashboard.html", dashboard_html.encode()),
|
||||||
(runtime_name, runtime),
|
(core.runtime_name, core.runtime_bytes),
|
||||||
|
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
|
||||||
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
||||||
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
||||||
(
|
(
|
||||||
|
|
@ -105,11 +137,12 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
|
|
||||||
return FrontendBuild(
|
return FrontendBuild(
|
||||||
dashboard_html=dashboard_html,
|
dashboard_html=dashboard_html,
|
||||||
runtime_bytes=runtime,
|
runtime_bytes=core.runtime_bytes,
|
||||||
runtime_gzip_bytes=runtime_gzip,
|
runtime_gzip_bytes=core.runtime_gzip_bytes,
|
||||||
runtime_digest=digest,
|
runtime_digest=core.runtime_digest,
|
||||||
runtime_name=runtime_name,
|
runtime_name=core.runtime_name,
|
||||||
shell_digest=shell_digest,
|
shell_digest=shell_digest,
|
||||||
page_sources=sources,
|
page_sources=sources,
|
||||||
|
feature_bundles=feature_bundles,
|
||||||
service_worker_source=worker,
|
service_worker_source=worker,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
17
src/views.py
17
src/views.py
|
|
@ -69,3 +69,20 @@ async def runtime_bundle(digest: str, request: Request) -> Response:
|
||||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feature-{name}-{digest}.js")
|
||||||
|
async def feature_bundle(name: str, digest: str, request: Request) -> Response:
|
||||||
|
bundle = FRONTEND_BUILD.feature_bundles.get(name)
|
||||||
|
if bundle is None or digest != bundle.runtime_digest:
|
||||||
|
raise HTTPException(status_code=404, detail="Feature revision not found")
|
||||||
|
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
|
||||||
|
return Response(
|
||||||
|
bundle.runtime_gzip_bytes if accepts_gzip else bundle.runtime_bytes,
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Vary": "Accept-Encoding",
|
||||||
|
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
71
tests/test_feature_loader.py
Normal file
71
tests/test_feature_loader.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
LOADER = Path(__file__).parents[1] / "frontend" / "feature-loader.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_loader(scenario: str) -> dict:
|
||||||
|
harness = f"""
|
||||||
|
const fs=require('fs'); const vm=require('vm');
|
||||||
|
const state={{appends:0, removed:0}};
|
||||||
|
const document={{
|
||||||
|
head:{{appendChild(node){{ state.appends++; state.node=node; }} }},
|
||||||
|
createElement(){{ return {{remove(){{state.removed++;}}}}; }},
|
||||||
|
}};
|
||||||
|
const context={{document, setTimeout, clearTimeout, console}};
|
||||||
|
context.globalThis=context;
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(LOADER))},'utf8'),context);
|
||||||
|
const createFeatureLoader=context.createFeatureLoader;
|
||||||
|
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_feature_requests_share_one_script():
|
||||||
|
result = run_loader("""
|
||||||
|
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||||
|
const first=loader.load('issue-capture'); const second=loader.load('issue-capture');
|
||||||
|
state.node.onload(); await Promise.all([first,second]);
|
||||||
|
console.log(JSON.stringify({appends:state.appends,same:first===second,ready:loader.ready('issue-capture')}));
|
||||||
|
""")
|
||||||
|
assert result == {"appends": 1, "same": True, "ready": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_feature_request_can_retry_without_reload():
|
||||||
|
result = run_loader("""
|
||||||
|
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||||
|
const failed=loader.load('issue-capture').catch(error=>error.message); state.node.onerror();
|
||||||
|
const message=await failed; const retry=loader.load('issue-capture'); state.node.onload(); await retry;
|
||||||
|
console.log(JSON.stringify({appends:state.appends,removed:state.removed,message,ready:loader.ready('issue-capture')}));
|
||||||
|
""")
|
||||||
|
assert result == {"appends": 2, "removed": 1, "message": "Could not load issue capture.", "ready": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_gate_exposes_loading_failure_and_success_to_mobile_controls():
|
||||||
|
result = run_loader("""
|
||||||
|
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||||
|
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
|
||||||
|
const failed=loader.run('issue-capture',{trigger,status},()=>{opened++;});
|
||||||
|
const loading={disabled:trigger.disabled,status:status.textContent}; state.node.onerror();
|
||||||
|
const first=await failed;
|
||||||
|
const failedState={disabled:trigger.disabled,status:status.textContent,opened};
|
||||||
|
const retry=loader.run('issue-capture',{trigger,status},()=>{opened++;}); state.node.onload();
|
||||||
|
const second=await retry;
|
||||||
|
console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus:status.textContent}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"loading": {"disabled": True, "status": "Loading issue capture…"},
|
||||||
|
"failedState": {
|
||||||
|
"disabled": False,
|
||||||
|
"status": "Issue capture could not load. Tap New issue to retry.",
|
||||||
|
"opened": 0,
|
||||||
|
},
|
||||||
|
"first": False,
|
||||||
|
"second": True,
|
||||||
|
"opened": 1,
|
||||||
|
"finalStatus": "",
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ from starlette.requests import Request
|
||||||
|
|
||||||
from src import main
|
from src import main
|
||||||
from src.frontend_bundle import build_frontend
|
from src.frontend_bundle import build_frontend
|
||||||
from src.views import dashboard, runtime_bundle, service_worker
|
from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
|
||||||
|
|
||||||
|
|
||||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||||
|
|
@ -42,6 +42,44 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||||
assert changed.runtime_bytes != first.runtime_bytes
|
assert changed.runtime_bytes != first.runtime_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_capture_is_a_stable_lazy_feature_chunk(tmp_path):
|
||||||
|
first = build_frontend(FRONTEND)
|
||||||
|
|
||||||
|
assert set(first.feature_bundles) == {"issue-capture"}
|
||||||
|
capture = first.feature_bundles["issue-capture"]
|
||||||
|
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||||
|
assert b"function createIssueCapture" in capture.runtime_bytes
|
||||||
|
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||||
|
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||||
|
|
||||||
|
changed_frontend = tmp_path / "frontend"
|
||||||
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
|
capture_source = changed_frontend / "create-issue-sheet.js"
|
||||||
|
capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n")
|
||||||
|
|
||||||
|
changed = build_frontend(changed_frontend)
|
||||||
|
assert changed.runtime_name == first.runtime_name
|
||||||
|
assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
|
||||||
|
build = build_frontend(FRONTEND)
|
||||||
|
capture = build.feature_bundles["issue-capture"]
|
||||||
|
|
||||||
|
request = Request(
|
||||||
|
{"type": "http", "method": "GET", "path": f"/{capture.runtime_name}",
|
||||||
|
"headers": [(b"accept-encoding", b"identity")]}
|
||||||
|
)
|
||||||
|
response = await feature_bundle("issue-capture", capture.runtime_digest, request)
|
||||||
|
|
||||||
|
assert response.body == capture.runtime_bytes
|
||||||
|
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||||
|
with pytest.raises(Exception) as rejected:
|
||||||
|
await feature_bundle("issue-capture", "missing", request)
|
||||||
|
assert rejected.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
|
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
|
||||||
first = build_frontend(FRONTEND)
|
first = build_frontend(FRONTEND)
|
||||||
changed_frontend = tmp_path / "frontend"
|
changed_frontend = tmp_path / "frontend"
|
||||||
|
|
|
||||||
|
|
@ -4337,6 +4337,14 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_issue_capture_waits_for_retryable_feature_before_opening():
|
||||||
|
dashboard_source = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
assert "await issueCaptureFeatures.run('issue-capture'" in dashboard_source
|
||||||
|
assert "if (!issueCapture)" in dashboard_source
|
||||||
|
assert "async function openCreateIssueSheet" in dashboard_source
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/icons/stackchain-192.png",
|
"/dashboard/static/icons/stackchain-192.png",
|
||||||
"/dashboard/static/icons/stackchain-512.png",
|
"/dashboard/static/icons/stackchain-512.png",
|
||||||
"/dashboard/static/session.js",
|
"/dashboard/static/session.js",
|
||||||
|
"/dashboard/static/feature-loader.js",
|
||||||
"/dashboard/static/markdown.js",
|
"/dashboard/static/markdown.js",
|
||||||
"/dashboard/static/commands.js",
|
"/dashboard/static/commands.js",
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user