Compare commits

...
Author SHA1 Message Date
timmy 285fdff869 fix: preserve Drafts when cleanup fails (Closes #625)
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:54:19 +00:00
timmy 89d153d6a5 Merge pull request 'Prevent silent mobile Draft loss at capacity' (#624) from timmy/623-draft-capacity-guard into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:38:48 +00:00
timmy fcb0575e38 feat: guard mobile Draft capacity (Closes #623)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:36:32 +00:00
timmy 14ba1bcf14 Merge pull request 'Complete the mobile Save to Drafts handoff' (#622) from timmy/621-mobile-draft-handoff into main
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:57:56 +00:00
timmy a68639668c feat: complete mobile Draft handoff (Closes #621)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:54:45 +00:00
timmy 1f0e9fb59d Merge pull request 'Make PWA updates resilient with progressive feature caching' (#620) from timmy/619-progressive-feature-caching into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:26:38 +00:00
timmy 065275b4ef feat: progressively cache optional PWA features (Closes #619)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:24:18 +00:00
12 changed files with 502 additions and 41 deletions

No files matched your search

+10 -1
View File
@@ -10,6 +10,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-capacity-actions button { min-height:44px; width:100%; }
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
#sign-out-all { min-height:44px; }
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.active-devices-sheet[hidden] { display:none; }
@@ -189,7 +197,8 @@ textarea { resize: vertical; min-height: 120px; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
+13 -3
View File
@@ -2078,7 +2078,8 @@
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
return '<article class="my-work-card draft-card">' +
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
@@ -4069,7 +4070,8 @@
body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(),
};
await unfiledCaptures.save(captureDraft);
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (resumedUnfiledCaptureId) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
@@ -4079,13 +4081,21 @@
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('drafts');
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
requestAnimationFrame(() => {
savedCard?.scrollIntoView({block:'nearest'});
savedCard?.focus({preventScroll:true});
});
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
} catch (error) {
qs('#create-issue-capture-status').textContent = error.message;
qs('#create-issue-title').focus();
}
});
bindDraftCapacityDialog();
qs('#use-shared-content').addEventListener('click', () => {
issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
+65
View File
@@ -0,0 +1,65 @@
function showDraftCapacityDialog(captures) {
const state = captures.capacity();
if (!state.full) return false;
const oldest = state.oldest;
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
qs('#draft-capacity-status').textContent = '';
qs('#draft-capacity-sheet').hidden = false;
qs('#keep-editing-draft').focus();
return true;
}
function bindDraftCapacityDialog() {
qs('#keep-editing-draft').addEventListener('click', () => {
qs('#draft-capacity-sheet').hidden = true;
qs('#save-unfiled-issue').focus();
});
qs('#review-full-drafts').addEventListener('click', () => {
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
saveIssueCaptureDraft();
qs('#draft-capacity-sheet').hidden = true;
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('drafts');
refreshMyWorkView();
requestAnimationFrame(() => {
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
card?.scrollIntoView({block:'nearest'});
card?.focus({preventScroll:true});
});
});
qs('#replace-oldest-draft').addEventListener('click', async () => {
const oldest = unfiledCaptures.capacity().oldest;
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
return;
}
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
const button = qs('#replace-oldest-draft');
button.disabled = true;
try {
const draft = {
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
attachment:await createIssueAttachmentController.serialize(),
};
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
qs('#draft-capacity-sheet').hidden = true;
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('drafts');
refreshMyWorkView();
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
} catch (error) {
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
} finally { button.disabled = false; }
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
+15
View File
@@ -593,6 +593,20 @@
</section>
</div>
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
<section class="draft-capacity-panel">
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
<div class="draft-capacity-actions">
<button id="review-full-drafts" type="button">Review Drafts</button>
<button id="keep-editing-draft" type="button">Keep editing</button>
<button id="replace-oldest-draft" type="button">Replace oldest &amp; save</button>
</div>
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
</section>
</div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel">
<div class="update-sheet-header">
@@ -844,6 +858,7 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
<script src="static/outbox-coordinator.js"></script>
<script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script>
+3 -1
View File
@@ -9,10 +9,12 @@
let wasHidden = false;
function select(name) {
if (!buttons[name]) return false;
Object.entries(buttons).forEach(([key, button]) => {
if (key === name) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
return true;
}
function refreshVisibility() {
@@ -68,5 +70,5 @@
else nav.removeAttribute('data-attention');
}
return {start, refreshVisibility, updateAttention, updateWork};
return {start, select, refreshVisibility, updateAttention, updateWork};
});
+17 -4
View File
@@ -21,6 +21,7 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
@@ -73,6 +74,8 @@ const SHELL = [
BASE + 'static/push-notifications.js',
BASE + 'static/background-issue-sync.js',
];
const OPTIONAL_FEATURES = [
];
async function fetchNavigation(request) {
const controller = new AbortController();
@@ -262,10 +265,16 @@ self.addEventListener('install', event => {
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
)).then(() => self.clients.claim()));
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
);
const cache = await caches.open(CACHE);
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
await self.clients.claim();
})());
});
self.addEventListener('sync', event => {
@@ -433,5 +442,9 @@ self.addEventListener('fetch', event => {
}
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
}
});
+69 -23
View File
@@ -30,7 +30,12 @@ function createUnfiledCaptures({
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
}
function save(note) {
function capacity() {
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
}
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
@@ -43,42 +48,83 @@ function createUnfiledCaptures({
if (hasAttachment && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
return {title, body, ownerLogin, attachment, hasAttachment};
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin, title, body, savedAt:Number(now()),
...(hasAttachment ? {hasAttachment:true} : {}),
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
};
const existing = read().filter(candidate => candidate.id !== item.id);
const items = [item, ...existing].slice(0, maxItems);
const pruned = existing.filter(candidate => !items.some(retained => retained.id === candidate.id));
if (!hasAttachment) {
write(items);
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
Promise.resolve(attachmentStore?.delete(candidate.id)).catch(() => {}));
return item;
}
return Promise.resolve(attachmentStore.put(item.id, {
filename:String(attachment.filename).slice(0, 255),
contentType:String(attachment.contentType), blob:attachment.blob,
})).then(() => {
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
try { write(items); }
catch (error) {
Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
Promise.resolve(attachmentStore.delete(candidate.id)).catch(() => {}));
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
: null;
if (!stage && !removed?.hasAttachment) return writeItems();
return Promise.resolve(stage).then(async () => {
if (!removed?.hasAttachment) return writeItems();
const removedAttachment = await attachmentStore.get(removed.id);
try {
await attachmentStore.delete(removed.id);
} catch (error) {
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
try { return writeItems(); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
throw error;
}
});
}
function save(note) {
const prepared = prepare(note);
const existing = read();
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
return persist(prepared, existing);
}
function replaceOldest(note, expectedOldestId) {
const prepared = prepare(note);
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
const oldest = existing.at(-1);
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
throw new Error('Drafts changed. Review them before replacing anything.');
}
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
}
function discard(id) {
const items = read();
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
write(remaining);
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) return true;
return Promise.resolve(attachmentStore?.delete(id)).then(() => true);
if (!removed?.hasAttachment) {
write(remaining);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
try { write(remaining); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
return true;
})
);
}
function resume(id, confirmedLogin) {
@@ -100,7 +146,7 @@ function createUnfiledCaptures({
function completeResume(id) { return discard(id); }
return {list, save, discard, resume, completeResume};
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
+11 -2
View File
@@ -29,7 +29,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/unfiled-captures.js",
"static/today-rollover.js", "static/unfiled-captures.js", "static/draft-capacity-dialog.js",
),
}
CACHE_DECLARATION = re.compile(
@@ -114,11 +114,20 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
eager_feature = feature_bundles["today-timer"]
optional_features = {
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
}
worker = worker.replace(
" BASE + 'static/dashboard.css',\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()),
+ f" BASE + '{eager_feature.runtime_name}',\n",
)
worker = worker.replace(
"const OPTIONAL_FEATURES = [\n",
"const OPTIONAL_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
+12
View File
@@ -77,6 +77,18 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
shell_block, optional_block = first.service_worker_source.split(
"const OPTIONAL_FEATURES = [", 1
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
for name, bundle in first.feature_bundles.items():
if name == "today-timer":
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
capture_source = changed_frontend / "create-issue-sheet.js"
+34
View File
@@ -116,6 +116,40 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const calls = [];
const makeButton = () => ({{
attributes: {{}},
setAttribute(name, value) {{ this.attributes[name] = value; }},
removeAttribute(name) {{ delete this.attributes[name]; }},
}});
const buttons = {{work:makeButton(), drafts:makeButton()}};
const dock = createDock({{
nav:{{}}, buttons,
actions:{{work:()=>calls.push('work'), drafts:()=>calls.push('drafts')}},
}});
const selected = dock.select('drafts');
process.stdout.write(JSON.stringify({{
selected, calls,
work:buttons.work.attributes['aria-current'] || null,
drafts:buttons.drafts.attributes['aria-current'] || null,
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"selected": True,
"calls": [],
"work": None,
"drafts": "page",
}
def test_mobile_task_dock_keeps_today_label_separate_from_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
+46 -2
View File
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -24,6 +24,10 @@ storedResponses.set(
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
add: async url => {{
if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable');
state.individuallyAdded.push(url);
}},
match: async request => {{
const key = String(request.url || request);
if (storedResponses.has(key)) return storedResponses.get(key).clone();
@@ -89,7 +93,7 @@ const context = {{
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf; self.__testOptionalFeatures = OPTIONAL_FEATURES;', context);
async function dispatch(name, request) {{
let pending;
let response;
@@ -680,6 +684,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/draft-capacity-dialog.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
@@ -746,6 +751,45 @@ def test_activate_deletes_only_stale_stackchain_caches():
assert result["deleted"] == ["stackchain-dashboard-old"]
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
result = run_worker_scenario(
"""
state.failedAdds = ['/dashboard/feature-security-center-test.js'];
context.self.__testOptionalFeatures.push(
'/dashboard/feature-issue-capture-test.js',
'/dashboard/feature-security-center-test.js',
'/dashboard/feature-pull-workflow-test.js',
);
await dispatch('activate');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["claimed"] is True
assert result["individuallyAdded"] == [
"/dashboard/feature-issue-capture-test.js",
"/dashboard/feature-pull-workflow-test.js",
]
def test_warmed_optional_feature_is_served_from_cache_while_offline():
result = run_worker_scenario(
"""
context.self.__testOptionalFeatures.push('/dashboard/feature-issue-capture-test.js');
state.cachedBody = 'cached feature';
state.failFetch = true;
const response = await dispatch('fetch', {
method: 'GET', mode: 'cors',
url: 'https://forge.example/dashboard/feature-issue-capture-test.js',
});
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
"""
)
assert result["body"] == "cached feature"
assert result["state"]["fetches"] == []
def test_offline_navigation_returns_cached_shell_for_share_target_url():
result = run_worker_scenario(
"""
+207 -5
View File
@@ -15,7 +15,7 @@ def run_node(script: str):
return json.loads(result.stdout)
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
@@ -32,25 +32,203 @@ const captures = createUnfiledCaptures({{
createId:()=>String(++sequence),
now:()=>1000 + sequence,
}});
for (let index = 1; index <= 22; index += 1) {{
for (let index = 1; index <= 20; index += 1) {{
captures.save({{title:'Note ' + index, body:'Context ' + index}});
}}
let fullError = '';
try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{
fullError = error.message;
}}
const beforeReplace = captures.list();
const oldest = captures.capacity().oldest;
const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id);
const offline = captures.list();
const restored = createUnfiledCaptures({{
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
}}).list();
process.stdout.write(JSON.stringify({{offline, restored}}));
process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}}));
"""
output = run_node(script)
assert output["fullError"] == "Drafts full — nothing was deleted."
assert len(output["beforeReplace"]) == 20
assert output["beforeReplace"][-1]["title"] == "Note 1"
assert output["oldest"]["title"] == "Note 1"
assert len(output["offline"]) == 20
assert output["offline"][0]["title"] == "Note 22"
assert output["offline"][-1]["title"] == "Note 3"
assert output["offline"][0]["title"] == "Note 21"
assert output["offline"][-1]["title"] == "Note 2"
assert all(item["quarantined"] for item in output["offline"])
assert all(not item["quarantined"] for item in output["restored"])
assert output["restored"][0]["ownerLogin"] == "timmy"
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const deleted = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => {{ deleted.push(id); blobs.delete(id); }},
}};
(async () => {{
let id = 0;
const captures = createUnfiledCaptures({{
storage, attachmentStore, maxItems:2, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>String(++id), now:()=>id,
}});
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}});
const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}});
let mismatch = '';
try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }}
catch (error) {{ mismatch = error.message; }}
const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id);
process.stdout.write(JSON.stringify({{
mismatch, titles:captures.list().map(item=>item.title), deleted,
blobs:[...blobs.keys()], third:third.title,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"mismatch": "Drafts changed. Review them before replacing anything.",
"titles": ["Third", "Second"],
"deleted": ["1"],
"blobs": ["2", "3"],
"third": "Third",
}
def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => {{
if (id === '1') throw new Error('IndexedDB delete failed');
blobs.delete(id);
}},
}};
(async () => {{
let id = 0;
const captures = createUnfiledCaptures({{
storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>String(++id), now:()=>id,
}});
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}});
let error = '';
try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }}
catch (caught) {{ error = caught.message; }}
const resumed = await captures.resume(original.id, 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()],
resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "IndexedDB delete failed",
"titles": ["Original"],
"blobs": ["1"],
"resumedTitle": "Original",
"resumedFilename": "original.png",
}
def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async () => {{ throw new Error('IndexedDB delete failed'); }},
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
await captures.save({{title:'Keep this',body:'Important',attachment:{{
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
}}}});
let error = '';
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
const resumed = await captures.resume('capture-1', 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title,
resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "IndexedDB delete failed",
"titles": ["Keep this"],
"resumedTitle": "Keep this",
"resumedFilename": "proof.png",
}
def test_discard_metadata_write_failure_restores_attachment_and_draft():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
let failWrites = false;
const storage = {{
getItem:k=>values.get(k)||null,
setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }},
}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => blobs.delete(id),
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
await captures.save({{title:'Keep this',body:'Important',attachment:{{
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
}}}});
failWrites = true;
let error = '';
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
failWrites = false;
const resumed = await captures.resume('capture-1', 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()],
resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "Storage write failed",
"titles": ["Keep this"],
"blobIds": ["capture-1"],
"resumedFilename": "proof.png",
}
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -151,6 +329,12 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "createUnfiledCaptures({" in html
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
assert "unfiledCaptures.save(captureDraft)" in html
assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html
assert "mobileTaskDock.select('drafts')" in html
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
assert "requestAnimationFrame(() =>" in html
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
@@ -160,9 +344,27 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "issueCapture.saveDraft(resumed)" in html
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html
assert 'scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
html = await dashboard()
assert 'id="draft-capacity-sheet"' in html
assert 'Drafts full — nothing was deleted.' in html
assert 'id="review-full-drafts"' in html
assert 'id="replace-oldest-draft"' in html
assert 'id="keep-editing-draft"' in html
assert "showDraftCapacityDialog(unfiledCaptures)" in html
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
assert '.draft-capacity-actions button { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields():
html = await dashboard()