feat(portability): version the backup, migrate older files, and keep AI provenance (#35)
Quality gates / quality (pull_request) Successful in 18m50s
Quality gates / quality (pull_request) Successful in 18m50s
A backup is the person's own record and has to keep working as the AI fields evolve. - Export stamps schema version 2 and carries the confirmed logs plus the training contribution receipts, including withdrawn tombstones; both are written through an allowlist so unknown keys and credentials never leave the device. - Import accepts version 1 and 2. A version 1 file is upgraded, the notice names the version it came from, and entries that predate provenance stay null instead of being given a source they never recorded. - A file from a newer Timmy is refused with an update instruction, malformed, oversized and over-limit files fail closed, and a refused import leaves the ledger unchanged. - A log saved from a confirmed or corrected AI suggestion records its provenance (source, suggested visible fields, model, confirmedAt) and the entry sheet states it. - Service worker shell cache v5 -> v6 so installed clients actually receive the change. Evidence: tests/portability.test.js (7 unit cases), tests/portability.acceptance.mjs (real browser: v1 upgrade, future refusal with unchanged ledger, provenance recorded, export v2, round trip), release demo scenes with on-camera caption proof, and the release builder gate portable_backup_restore. Refs #35
This commit is contained in:
1 parent
baabeb0f4f
commit
2094a07d9f
16 files changed
+611
-23
No files matched your search
@@ -60,6 +60,7 @@ jobs:
|
||||
npm run test:pattern-summary
|
||||
npm run test:photo-lifecycle
|
||||
npm run test:training-contribution
|
||||
npm run test:portability
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high
|
||||
- name: Syntax checks
|
||||
|
||||
@@ -122,7 +122,7 @@ npm audit --audit-level=high
|
||||
- A committed CC0 synthetic vision corpus that continuously proves every reply is schema-valid or fails closed. A schema-valid reply with no readable form or colour now **abstains** ("No confident match") instead of surfacing as a worker failure, and unparsable or out-of-range replies return plain copy — "No suggestion was applied. Continue manually, or try a clearer photo." — with no provider text, image bytes, or credentials echoed. See [tests/fixtures/vision-corpus](tests/fixtures/vision-corpus/README.md)
|
||||
- Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies
|
||||
- Private photo lifecycle: a confirmed log reports a saved photo as a label rather than a thumbnail, showing the image is an explicit request, removing the photo keeps every confirmed field, deleting a log is a separate two-step decision, and delete-all removes the ledger and every stored photo together
|
||||
- Local browser ledger, calendar, pattern summary, JSON portability, and delete-all
|
||||
- Local browser ledger, calendar, pattern summary, versioned JSON backup (logs, training receipts, and per-log AI provenance) with safe upgrade/refusal on import, and delete-all
|
||||
- Explainable pattern summaries: the home insight card reports the confirmed-log count and form groupings for the last 28 days, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend. No line explains a cause, rules on food, or offers a health verdict
|
||||
- Installable/offline PWA shell for the manual and saved-ledger paths
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bucketForBristolType, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
||||
import { bucketForBristolType, detectUrgentFlags, detectUrgentText, exportLedgerBackup, LEDGER_EXPORT_VERSION, hasUrgentLedgerContext, importLedgerBackup, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
||||
import { mergeVisualSuggestion } from './src/analysis.js';
|
||||
import { createDraft, draftNotice, isDraftDirty } from './src/draft.js';
|
||||
import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js';
|
||||
@@ -42,6 +42,10 @@ let chatBusy = false;
|
||||
let chatError = '';
|
||||
let chatMessages = [{ role: 'timmy', text: 'Ask me about your confirmed logs, visible patterns, privacy, or how Timmy works.' }];
|
||||
let draftResumed = false;
|
||||
// Issue #35 — importing a backup reports which version the file came from and what changed, and a
|
||||
// refused file leaves the ledger exactly as it was. The notice is state, not a toast, because a
|
||||
// message that disappears in two seconds cannot tell anyone whether their records were replaced.
|
||||
let portabilityNotice = null;
|
||||
let form = createDraft();
|
||||
let followUpAnswers = createFollowUpAnswers();
|
||||
|
||||
@@ -131,7 +135,7 @@ function showEntrySheet(id, armed=false){
|
||||
const urgent=detectUrgentFlags(entry.symptoms);
|
||||
document.querySelector('.sheet-backdrop')?.remove();
|
||||
const wrap=document.createElement('div');wrap.className='sheet-backdrop';
|
||||
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="entry-sheet-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Confirmed log</span><h2 id="entry-sheet-title">${formatDate(entry.occurredAt)}</h2></div><button class="icon-btn" id="entry-sheet-close" aria-label="Close">×</button></div><div class="entry-facts"><div><span>Form</span><strong>Type ${entry.bristolType}</strong></div><div><span>Color</span><strong>${esc(entry.color)}</strong></div><div><span>Urgency</span><strong>${entry.urgency}/4</strong></div><div><span>Discomfort</span><strong>${entry.discomfort}/4</strong></div></div>${entry.note?`<p class="fine entry-note">${esc(entry.note)}</p>`:''}${urgent.urgent?alertHtml(urgent.message):''}${photo.hasPhoto?`<section class="photo-lifecycle" data-photo-lifecycle="present"><strong>Photo saved with this log</strong><p class="fine">The image stays in this browser. The journal never shows it as a thumbnail until you ask.</p><div id="entry-photo-slot"></div><div class="row"><button class="btn btn-secondary" id="show-entry-photo">Show photo</button><button class="btn btn-ghost" id="remove-entry-photo">Remove photo, keep log</button></div></section>`:`<section class="photo-lifecycle" data-photo-lifecycle="absent"><strong>No photo saved</strong><p class="fine">This confirmed log is text only. Its record does not depend on an image.</p></section>`}<div class="entry-actions">${armed?`<button class="btn btn-danger btn-wide" id="delete-entry-confirm">Tap again to delete this log and its photo</button><p class="fine">This removes the confirmed log and its saved photo from this browser. It cannot be undone.</p>`:`<button class="btn btn-ghost btn-wide" id="delete-entry">Delete this log and its photo</button>`}</div></section>`;
|
||||
wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="entry-sheet-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Confirmed log</span><h2 id="entry-sheet-title">${formatDate(entry.occurredAt)}</h2></div><button class="icon-btn" id="entry-sheet-close" aria-label="Close">×</button></div><div class="entry-facts"><div><span>Form</span><strong>Type ${entry.bristolType}</strong></div><div><span>Color</span><strong>${esc(entry.color)}</strong></div><div><span>Urgency</span><strong>${entry.urgency}/4</strong></div><div><span>Discomfort</span><strong>${entry.discomfort}/4</strong></div></div>${entry.note?`<p class="fine entry-note">${esc(entry.note)}</p>`:''}${entry.provenance?`<p class="fine entry-provenance" data-entry-provenance="${esc(entry.provenance.source)}">${esc(entry.provenance.source==='ai-corrected'?'Saved from an AI suggestion you corrected. Your values are stored, not the model’s.':'Saved from an AI suggestion you confirmed: visible form and color only, never a diagnosis.')}${entry.provenance.model?` Model: ${esc(entry.provenance.model)}.`:''}</p>`:''}${urgent.urgent?alertHtml(urgent.message):''}${photo.hasPhoto?`<section class="photo-lifecycle" data-photo-lifecycle="present"><strong>Photo saved with this log</strong><p class="fine">The image stays in this browser. The journal never shows it as a thumbnail until you ask.</p><div id="entry-photo-slot"></div><div class="row"><button class="btn btn-secondary" id="show-entry-photo">Show photo</button><button class="btn btn-ghost" id="remove-entry-photo">Remove photo, keep log</button></div></section>`:`<section class="photo-lifecycle" data-photo-lifecycle="absent"><strong>No photo saved</strong><p class="fine">This confirmed log is text only. Its record does not depend on an image.</p></section>`}<div class="entry-actions">${armed?`<button class="btn btn-danger btn-wide" id="delete-entry-confirm">Tap again to delete this log and its photo</button><p class="fine">This removes the confirmed log and its saved photo from this browser. It cannot be undone.</p>`:`<button class="btn btn-ghost btn-wide" id="delete-entry">Delete this log and its photo</button>`}</div></section>`;
|
||||
document.body.append(wrap);
|
||||
if(entrySheetKeyHandler)document.removeEventListener('keydown',entrySheetKeyHandler);
|
||||
const close=()=>{wrap.remove();document.removeEventListener('keydown',onKey);if(entrySheetKeyHandler===onKey)entrySheetKeyHandler=null};
|
||||
@@ -309,12 +313,13 @@ function withdrawTrainingContribution(contributionId){
|
||||
}
|
||||
|
||||
function privacy(){
|
||||
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section>${trainingConsentSection()}<section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
|
||||
shell(`<main><div class="page-title"><span class="eyebrow">Private by design</span><h1>Your poop. Your phone.</h1><p>This prototype has no account, analytics, ad tracker, or server database.</p></div><section class="card privacy-list"><div class="privacy-item"><b>⌂</b><div><h3>Stored locally</h3><p>Saved entries and optional photos live in this browser’s local storage. You can remove one photo without deleting its confirmed log.</p></div></div><div class="privacy-item"><b>⇩</b><div><h3>Portable</h3><p>Export a readable JSON file. Import it in another copy of Timmy.</p></div></div><div class="privacy-item"><b>◎</b><div><h3>AI only when you ask</h3><p>Manual logging never uploads. Analyze a photo sends one compressed copy after consent. Hermes chat may receive up to 20 confirmed text-only entries after you connect; photos and backend credentials never enter chat.</p></div></div></section><section class="section card"><h2>Data controls</h2><div class="row"><button class="btn btn-primary" id="export">Export JSON</button><label class="btn btn-secondary" for="import">Import JSON</label><input class="hidden" type="file" id="import" accept="application/json"></div><p class="fine section">Exports can contain sensitive health notes and photos. Store them somewhere you trust.</p></section>${portabilityNoticeBlock()}${trainingConsentSection()}<section class="section card source-list"><h2>Health sources</h2><p class="fine">The Bristol groupings and urgent-symptom copy are grounded in public clinical guidance.</p><p><a target="_blank" rel="noreferrer" href="https://www.continence.org.au/about-incontinence/bowel-incontinence/bristol-stool-chart/">Continence Health Australia</a></p><p><a target="_blank" rel="noreferrer" href="https://www.nhs.uk/conditions/bleeding-from-the-bottom-rectal-bleeding/">NHS rectal bleeding guidance</a></p><p><a target="_blank" rel="noreferrer" href="https://www.niddk.nih.gov/health-information/digestive-diseases/constipation/symptoms-causes">NIDDK constipation guidance</a></p></section><section class="section card danger-zone"><h2>Delete everything</h2><p class="fine">Permanently removes Timmy’s local ledger and every saved photo from this browser.</p><button class="btn btn-danger" id="delete-all">Delete all local data</button></section></main>`);
|
||||
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
|
||||
}
|
||||
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
|
||||
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
|
||||
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];contributions=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);localStorage.removeItem(TRAINING_STORE);render();toast('Local ledger and photos deleted')}}
|
||||
function exportData(){const blob=new Blob([exportLedgerBackup({entries,contributions})],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`timmy-backup-v${LEDGER_EXPORT_VERSION}.json`;a.click();URL.revokeObjectURL(a.href);portabilityNotice={kind:'ok',version:LEDGER_EXPORT_VERSION,text:`Backup written as version ${LEDGER_EXPORT_VERSION}: ${entries.length} log${entries.length===1?'':'s'} and ${contributions.length} training receipt${contributions.length===1?'':'s'}.`};render();toast('Backup exported');}
|
||||
async function importData(e){const file=e.target.files?.[0];if(!file)return;try{const result=importLedgerBackup(await file.text());entries=result.entries;contributions=result.contributions;saveEntries();saveContributions();portabilityNotice={kind:'ok',version:result.fromVersion,text:[`Restored ${result.entries.length} log${result.entries.length===1?'':'s'} and ${result.contributions.length} training receipt${result.contributions.length===1?'':'s'} from a version ${result.fromVersion} backup.`,...result.notices].join(' ')};e.target.value='';render();toast('Backup imported')}catch(err){portabilityNotice={kind:'error',text:`${err.message||'That backup could not be read.'} Your ledger is unchanged.`};e.target.value='';render();toast('Nothing was imported')}}
|
||||
function portabilityNoticeBlock(){if(!portabilityNotice)return '';if(portabilityNotice.kind==='error')return `<p class="portability-notice error" role="alert" data-portability-error>${esc(portabilityNotice.text)}</p>`;return `<p class="portability-notice" role="status" data-portability-notice data-portability-version="${esc(String(portabilityNotice.version))}">${esc(portabilityNotice.text)}</p>`}
|
||||
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];contributions=[];portabilityNotice=null;localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);localStorage.removeItem(TRAINING_STORE);render();toast('Local ledger and photos deleted')}}
|
||||
|
||||
function openPhotoFirst(){beginDraft();photoHint='';resetSuggestion();visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
||||
async function loadVisionStatus(){
|
||||
@@ -371,7 +376,21 @@ function bindStep(step){
|
||||
}
|
||||
async function handlePhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showLogStep(2)}catch{photoHint='That image could not be read. Try another photo.';showLogStep(2)}}
|
||||
function compressPhoto(file){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{const scale=Math.min(1,1200/Math.max(img.width,img.height)),canvas=document.createElement('canvas');canvas.width=Math.round(img.width*scale);canvas.height=Math.round(img.height*scale);const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,canvas.width,canvas.height);const sample=ctx.getImageData(0,0,Math.min(canvas.width,120),Math.min(canvas.height,120)).data;let total=0;for(let i=0;i<sample.length;i+=4)total+=(sample[i]+sample[i+1]+sample[i+2])/3;URL.revokeObjectURL(url);resolve({dataUrl:canvas.toDataURL('image/jpeg',.7),width:img.width,height:img.height,brightness:total/(sample.length/4)/255})};img.onerror=()=>{URL.revokeObjectURL(url);reject()};img.src=url})}
|
||||
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}clearDraft();document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')}
|
||||
// Issue #35 — if this log began as an AI suggestion, the saved record says so. The fields are
|
||||
// limited to what the app actually knows (which visible fields were suggested, whether they were
|
||||
// confirmed or corrected, and which model answered), so a backup cannot be read as a diagnosis or a
|
||||
// broader claim than the visual suggestion the person accepted.
|
||||
function provenanceForSave(){
|
||||
const status=suggestionState?.status;
|
||||
if(status!=='confirmed'&&status!=='corrected')return null;
|
||||
const suggestion=suggestionState.suggestion;
|
||||
if(!suggestion)return null;
|
||||
const provenance={source:status==='confirmed'?'ai-confirmed':'ai-corrected',suggestedBristolType:suggestion.bristolType,suggestedColor:suggestion.color,confirmedAt:new Date().toISOString()};
|
||||
const model=typeof visionStatus?.model==='string'?visionStatus.model.trim():'';
|
||||
if(model)provenance.model=model.slice(0,80);
|
||||
return provenance;
|
||||
}
|
||||
function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=sanitizeEntry({...form,provenance:provenanceForSave()});try{entries.push(entry);saveEntries()}catch{entry.photoDataUrl='';entries[entries.length-1]=entry;saveEntries();toast('Log saved, but the photo was too large for browser storage')}clearDraft();document.querySelector('.sheet-backdrop')?.remove();view='home';render();toast(result.urgent?'Saved. Please follow the medical-care alert.':'Private log saved')}
|
||||
function render(){({home,calendar,timmy,privacy}[view]||home)()}
|
||||
|
||||
render();
|
||||
|
||||
@@ -56,6 +56,16 @@ Human gates are limited to:
|
||||
|
||||
Routine provisioning, deployment, label sweeps, and release-candidate assembly are not human-operated gates.
|
||||
|
||||
### Backup portability and entry provenance
|
||||
|
||||
A backup is the person's own record and must keep working as the AI fields evolve.
|
||||
|
||||
- Every export stamps a schema version (currently version 2). The same file carries the confirmed logs and the training contribution receipts, including withdrawn tombstones.
|
||||
- An older backup is upgraded on import, and the app says which version the file came from. Fields that did not exist when the file was written are left empty rather than invented.
|
||||
- A backup from a newer version is refused with an instruction to update Timmy, and a refused import leaves the ledger unchanged.
|
||||
- Malformed, oversized, and over-limit files fail before anything is replaced.
|
||||
- Provenance is stored on the confirmed log when a saved entry began as a confirmed or corrected AI suggestion: source, suggested visible fields, the model that answered, and when it was confirmed. It never records a diagnosis, a symptom, or provider credentials, and backups are written through an allowlist so unknown keys never leave the device.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Model output is assistive and fail-closed, never authoritative.
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js",
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/training-contribution.test.js tests/portability.test.js",
|
||||
"test:ui": "node tests/ui.acceptance.mjs",
|
||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||
"test:mobile-capture": "node tests/mobile-capture.acceptance.mjs",
|
||||
@@ -18,10 +18,11 @@
|
||||
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
|
||||
"test:photo-lifecycle": "node tests/photo-lifecycle.acceptance.mjs",
|
||||
"test:training-contribution": "node tests/training-contribution.acceptance.mjs",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
|
||||
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check src/training-contribution.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/training-contribution.acceptance.mjs && node --check tests/portability.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh",
|
||||
"check:diff": "bash scripts/check_diff.sh",
|
||||
"start": "node server.mjs",
|
||||
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
|
||||
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs",
|
||||
"test:portability": "node tests/portability.acceptance.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
|
||||
@@ -54,6 +54,10 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"Training contribution is off by default: a saved log is never training data",
|
||||
"The consent version and the receipt are on screen before the contribute button works",
|
||||
"Withdrawing keeps a tombstone naming every derived artifact by contribution ID",
|
||||
"A backup is versioned, and it carries the withdrawn receipt as a tombstone rather than silence",
|
||||
"An older backup is upgraded on import, and the notice says which version the file came from",
|
||||
"A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched",
|
||||
"A restored log still says it began as an AI suggestion you corrected — provenance is part of the record",
|
||||
)
|
||||
|
||||
|
||||
@@ -156,6 +160,7 @@ def main() -> int:
|
||||
run(["npm", "run", "test:grounded-chat"], tree)
|
||||
run(["npm", "run", "test:photo-lifecycle"], tree)
|
||||
run(["npm", "run", "test:training-contribution"], tree)
|
||||
run(["npm", "run", "test:portability"], tree)
|
||||
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
|
||||
demo_env = dict(server_env)
|
||||
@@ -290,7 +295,7 @@ def main() -> int:
|
||||
"codec": "h264",
|
||||
"pixel_format": "yuv420p",
|
||||
"recorded_from_working_app": True,
|
||||
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact",
|
||||
"fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact, plus a versioned backup that carries the logs and the withdrawn receipt, an older version 1 file upgraded with the version named on screen, a file from a newer Timmy refused without changing the ledger, and a restored log still showing it began as a corrected AI suggestion",
|
||||
},
|
||||
"gates": {
|
||||
"unit_security": "passed",
|
||||
@@ -306,6 +311,7 @@ def main() -> int:
|
||||
"longitudinal_pattern_summary": "passed",
|
||||
"grounded_chat_citations": "passed",
|
||||
"training_contribution_consent": "passed",
|
||||
"portable_backup_restore": "passed",
|
||||
"dependency_audit_high": "passed",
|
||||
"syntax": "passed",
|
||||
"secret_scan": "passed",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { analyzePhoto, visionFailureResponse } from '../src/vision-service.js';
|
||||
@@ -520,6 +520,101 @@ for (const artifact of tombstone.artifacts) {
|
||||
}
|
||||
await caption('Withdrawing keeps a tombstone naming every derived artifact by contribution ID', 3000);
|
||||
await indicate('[data-withdrawn-artifacts]');
|
||||
|
||||
// Issue #35 — a backup is the person's own record, so it carries a version and everything it is
|
||||
// claimed to hold. The recording exports the real file, imports an older version and a newer one,
|
||||
// and reopens a restored log. Fictional sample logs are used throughout; nothing here is real data.
|
||||
const exportDownload = page.waitForEvent('download');
|
||||
await tap('#export', 600);
|
||||
const exportedBackup = JSON.parse(await readFile(await (await exportDownload).path(), 'utf8'));
|
||||
if (exportedBackup.schemaVersion !== 2) throw new Error('the exported backup must declare the current schema version');
|
||||
if (!Array.isArray(exportedBackup.entries) || !Array.isArray(exportedBackup.contributions)) {
|
||||
throw new Error('a backup must carry both the confirmed logs and the contribution receipts');
|
||||
}
|
||||
const exportedReceipt = exportedBackup.contributions.find(receipt => receipt.contributionId === receiptId);
|
||||
if (!exportedReceipt || exportedReceipt.state !== 'withdrawn') {
|
||||
throw new Error('the backup must carry the withdrawn receipt as a tombstone');
|
||||
}
|
||||
if (!(exportedReceipt.artifacts || []).length) throw new Error('the backup tombstone must still name its derived artifacts');
|
||||
await page.locator('[data-portability-notice][data-portability-version="2"]').waitFor();
|
||||
await caption('A backup is versioned, and it carries the withdrawn receipt as a tombstone rather than silence', 3200);
|
||||
|
||||
const fictionalLegacyBackup = JSON.stringify({
|
||||
product: 'Timmy the Talking Turd',
|
||||
schemaVersion: 1,
|
||||
exportedAt: '2026-08-01T09:00:00.000Z',
|
||||
entries: [{
|
||||
id: 'demo-legacy-backup',
|
||||
occurredAt: new Date(Date.now() - 3 * 86400000).toISOString(),
|
||||
bristolType: 3,
|
||||
color: 'brown',
|
||||
urgency: 1,
|
||||
discomfort: 0,
|
||||
note: 'fictional version 1 backup',
|
||||
photoDataUrl: '',
|
||||
symptoms: {},
|
||||
}],
|
||||
});
|
||||
await page.locator('#import').setInputFiles({ name: 'timmy-backup-v1.json', mimeType: 'application/json', buffer: Buffer.from(fictionalLegacyBackup) });
|
||||
await page.locator('[data-portability-notice][data-portability-version="1"]').waitFor();
|
||||
const upgradeNotice = (await page.locator('[data-portability-notice]').innerText()).trim();
|
||||
if (!/upgraded/i.test(upgradeNotice)) throw new Error('an older backup must be reported as upgraded, not silently rewritten');
|
||||
await caption('An older backup is upgraded on import, and the notice says which version the file came from', 3400);
|
||||
|
||||
const ledgerBeforeFuture = await storedLedger();
|
||||
await page.locator('#import').setInputFiles({
|
||||
name: 'timmy-backup-v3.json', mimeType: 'application/json',
|
||||
buffer: Buffer.from(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 3, entries: [{ id: 'demo-future', bristolType: 7 }] })),
|
||||
});
|
||||
await page.locator('[data-portability-error]').waitFor();
|
||||
if (!/newer version of Timmy/i.test(await page.locator('[data-portability-error]').innerText())) {
|
||||
throw new Error('a backup from a newer Timmy must be refused with an instruction to update');
|
||||
}
|
||||
if ((await storedLedger()).raw !== ledgerBeforeFuture.raw) {
|
||||
throw new Error('a refused import must not change the ledger it would have replaced');
|
||||
}
|
||||
await caption('A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched', 3400);
|
||||
|
||||
const fictionalAssistedBackup = JSON.stringify({
|
||||
product: 'Timmy the Talking Turd',
|
||||
schemaVersion: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
entries: [{
|
||||
id: 'demo-restored-provenance',
|
||||
occurredAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
bristolType: 2,
|
||||
color: 'brown',
|
||||
urgency: 1,
|
||||
discomfort: 0,
|
||||
note: 'fictional restored log',
|
||||
photoDataUrl: '',
|
||||
symptoms: {},
|
||||
provenance: {
|
||||
source: 'ai-corrected',
|
||||
model: 'SmolVLM2-2.2B-Instruct',
|
||||
suggestedBristolType: 4,
|
||||
suggestedColor: 'brown',
|
||||
confirmedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
}],
|
||||
contributions: [],
|
||||
});
|
||||
await page.locator('#import').setInputFiles({ name: 'timmy-backup-v2.json', mimeType: 'application/json', buffer: Buffer.from(fictionalAssistedBackup) });
|
||||
await page.locator('[data-portability-notice][data-portability-version="2"]').waitFor();
|
||||
await tap('[data-view="calendar"]', 550);
|
||||
await page.locator('[data-entry-open="demo-restored-provenance"]').waitFor();
|
||||
await tap('[data-entry-open="demo-restored-provenance"]', 550);
|
||||
await page.locator('[data-entry-provenance="ai-corrected"]').waitFor();
|
||||
const provenanceLine = (await page.locator('[data-entry-provenance="ai-corrected"]').innerText()).trim();
|
||||
if (!/corrected/i.test(provenanceLine) || !/SmolVLM2/.test(provenanceLine)) {
|
||||
throw new Error('a restored log must still show that it began as a corrected AI suggestion');
|
||||
}
|
||||
await caption('A restored log still says it began as an AI suggestion you corrected — provenance is part of the record', 3400);
|
||||
await indicate('[data-entry-provenance="ai-corrected"]');
|
||||
await tap('#entry-sheet-close', 450);
|
||||
await tap('[data-view="privacy"]', 550);
|
||||
await page.locator('#delete-all').waitFor();
|
||||
|
||||
await indicate('#delete-all');
|
||||
await tap('#delete-all', 650);
|
||||
await page.getByText(/Local ledger and photos deleted/).waitFor();
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
const ROOT = new URL(self.registration.scope).pathname;
|
||||
const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`;
|
||||
const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`;
|
||||
const CACHE = `${CACHE_NAMESPACE}v5`;
|
||||
const CACHE = `${CACHE_NAMESPACE}v6`;
|
||||
const ASSETS = [
|
||||
'',
|
||||
'index.html',
|
||||
|
||||
+127
-5
@@ -111,9 +111,32 @@ export function sanitizeEntry(input = {}) {
|
||||
note: String(input.note || '').trim().slice(0, 500),
|
||||
photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '',
|
||||
symptoms,
|
||||
provenance: sanitizeProvenance(input.provenance),
|
||||
};
|
||||
}
|
||||
|
||||
// Provenance is the only record of whether a confirmed log began as an AI suggestion. It is kept to
|
||||
// an allowlist so a backup can never smuggle a credential, a prompt, or a raw model reply out of the
|
||||
// device, and it is `null` for logs that never had one — a missing record is not a manual entry.
|
||||
const PROVENANCE_SOURCES = Object.freeze(['ai-confirmed', 'ai-corrected']);
|
||||
|
||||
function sanitizeProvenance(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
if (!PROVENANCE_SOURCES.includes(value.source)) return null;
|
||||
const provenance = { source: value.source };
|
||||
if (typeof value.model === 'string' && value.model.trim()) provenance.model = value.model.trim().slice(0, 80);
|
||||
const suggestedType = Number(value.suggestedBristolType);
|
||||
if (Number.isInteger(suggestedType) && suggestedType >= 1 && suggestedType <= 7) {
|
||||
provenance.suggestedBristolType = suggestedType;
|
||||
}
|
||||
if (['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(value.suggestedColor)) {
|
||||
provenance.suggestedColor = value.suggestedColor;
|
||||
}
|
||||
const confirmedAt = value.confirmedAt ? new Date(value.confirmedAt) : null;
|
||||
if (confirmedAt && !Number.isNaN(confirmedAt.valueOf())) provenance.confirmedAt = confirmedAt.toISOString();
|
||||
return provenance;
|
||||
}
|
||||
|
||||
export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) {
|
||||
if (width < 640 || height < 480) return 'Move a little closer or use a higher-resolution photo. The image stays on this device.';
|
||||
if (brightness < 0.12) return 'Add more light before saving. Timmy only checks whether the photo is usable.';
|
||||
@@ -121,19 +144,118 @@ export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 }
|
||||
return 'Ready for your review. Choose the matching Bristol form yourself; Timmy does not interpret the picture.';
|
||||
}
|
||||
|
||||
export const LEDGER_EXPORT_VERSION = 2;
|
||||
|
||||
export function exportLedger(entries, exportedAt = new Date().toISOString()) {
|
||||
return exportLedgerBackup({ entries, exportedAt });
|
||||
}
|
||||
|
||||
// A backup is written through an allowlist, so only fields this build understands leave the device.
|
||||
// Entries are re-sanitized on the way out as well as in: an unknown key on an entry is not part of
|
||||
// the person's record, and a crafted key must never be laundered through a backup.
|
||||
export function exportLedgerBackup({ entries = [], contributions = [], exportedAt = new Date().toISOString() } = {}) {
|
||||
const list = Array.isArray(entries) ? entries.map(sanitizeEntry) : [];
|
||||
const receipts = (Array.isArray(contributions) ? contributions : []).map(sanitizeContribution).filter(Boolean);
|
||||
return JSON.stringify({
|
||||
product: 'Timmy the Talking Turd',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: LEDGER_EXPORT_VERSION,
|
||||
exportedAt,
|
||||
entries: Array.isArray(entries) ? entries : [],
|
||||
entries: list,
|
||||
contributions: receipts,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
const CONTRIBUTION_STATES = Object.freeze(['queued-local', 'withdrawn']);
|
||||
const CONTRIBUTION_ARTIFACTS = Object.freeze(['raw-image', 'confirmed-label']);
|
||||
|
||||
function isoOrNull(value) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.valueOf()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function sanitizeContribution(input = {}) {
|
||||
const contributionId = String(input?.contributionId || '').trim().slice(0, 120);
|
||||
if (!contributionId) return null;
|
||||
const state = CONTRIBUTION_STATES.includes(input.state) ? input.state : 'queued-local';
|
||||
const receipt = {
|
||||
contributionId,
|
||||
entryId: String(input.entryId || '').trim().slice(0, 120),
|
||||
consentVersion: String(input.consentVersion || '').trim().slice(0, 60),
|
||||
consentedAt: isoOrNull(input.consentedAt),
|
||||
labels: {
|
||||
bristolType: Math.min(7, Math.max(1, Number(input.labels?.bristolType) || 4)),
|
||||
color: ['brown', 'green', 'yellow', 'pale', 'red', 'black'].includes(input.labels?.color) ? input.labels.color : 'brown',
|
||||
},
|
||||
state,
|
||||
};
|
||||
const withdrawnAt = isoOrNull(input.withdrawnAt);
|
||||
if (state === 'withdrawn' && withdrawnAt) receipt.withdrawnAt = withdrawnAt;
|
||||
if (state === 'withdrawn') {
|
||||
receipt.artifacts = (Array.isArray(input.artifacts) ? input.artifacts : [])
|
||||
.filter(artifact => CONTRIBUTION_ARTIFACTS.includes(artifact?.artifact))
|
||||
.map(artifact => ({
|
||||
artifact: artifact.artifact,
|
||||
contributionId: String(artifact.contributionId || contributionId).slice(0, 120),
|
||||
state: artifact.state === 'withdrawn' ? 'withdrawn' : 'active',
|
||||
}));
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export function importLedger(text) {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.');
|
||||
return parsed.entries.map(sanitizeEntry);
|
||||
return importLedgerBackup(text).entries;
|
||||
}
|
||||
|
||||
// A backup can be older than this build. Version 1 files record no provenance for their entries, so
|
||||
// the migration leaves that field empty instead of guessing a source, and the caller is told which
|
||||
// version the file came from.
|
||||
export const LEDGER_IMPORT_LIMITS = Object.freeze({ maxTextLength: 16 * 1024 * 1024, maxEntries: 5000 });
|
||||
|
||||
export function importLedgerBackup(text) {
|
||||
const raw = String(text ?? '');
|
||||
if (raw.length > LEDGER_IMPORT_LIMITS.maxTextLength) {
|
||||
throw new Error('That backup is too large for Timmy to read safely. Nothing was imported.');
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('That file is not readable JSON, so nothing was imported.');
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
|
||||
throw new Error('This is not a supported Timmy backup.');
|
||||
}
|
||||
const fromVersion = Number(parsed.schemaVersion);
|
||||
if (Number.isInteger(fromVersion) && fromVersion > LEDGER_EXPORT_VERSION) {
|
||||
throw new Error(`This backup was written by a newer version of Timmy (version ${fromVersion}). Update Timmy before importing it; nothing was imported.`);
|
||||
}
|
||||
if (!Number.isInteger(fromVersion) || fromVersion < 1) {
|
||||
throw new Error('This is not a supported Timmy backup.');
|
||||
}
|
||||
if (parsed.entries.length > LEDGER_IMPORT_LIMITS.maxEntries) {
|
||||
throw new Error('That backup holds more logs than Timmy will import at once. Nothing was imported.');
|
||||
}
|
||||
const entries = parsed.entries.map(sanitizeEntry);
|
||||
const rawContributions = Array.isArray(parsed.contributions) ? parsed.contributions : [];
|
||||
const contributions = rawContributions.map(sanitizeContribution).filter(Boolean);
|
||||
const migrated = fromVersion < LEDGER_EXPORT_VERSION;
|
||||
const notices = migrated
|
||||
? [`Restored ${entries.length} ${entries.length === 1 ? 'log' : 'logs'} from a version ${fromVersion} backup and upgraded it to version ${LEDGER_EXPORT_VERSION}.`]
|
||||
: [];
|
||||
// Skipping an unreadable receipt silently would tell someone their opt-in vanished. Say it.
|
||||
const skipped = rawContributions.length - contributions.length;
|
||||
if (skipped > 0) {
|
||||
notices.push(`${skipped} training contribution ${skipped === 1 ? 'receipt' : 'receipts'} could not be read and ${skipped === 1 ? 'was' : 'were'} skipped.`);
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
contributions,
|
||||
fromVersion,
|
||||
migrated,
|
||||
notices,
|
||||
exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const urgentSymptomKeys = Object.freeze([...URGENT_KEYS]);
|
||||
@@ -70,3 +70,8 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
|
||||
.contribution-row span{font-size:11px;line-height:1.4;color:var(--muted);overflow-wrap:anywhere}
|
||||
.contribution-row .btn{min-height:40px;padding:0 14px;font-size:12px;flex:0 0 auto}
|
||||
.contribution-row.withdrawn strong{color:var(--muted)}
|
||||
|
||||
/* Issue #35 — a backup says which version it came from, and a refused file says so in place. */
|
||||
.portability-notice{margin:0 0 12px;padding:12px 14px;border-radius:15px;font-size:12.5px;line-height:1.45;font-weight:700;background:var(--teal-soft);color:#155c4f;border:1px solid #bcdfd4}
|
||||
.portability-notice.error{background:var(--red-soft);border-color:#eec1ba;color:#75261f}
|
||||
.entry-provenance{margin:10px 0 0;padding-top:9px;border-top:1px solid rgba(55,40,31,.09)}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
detectUrgentText,
|
||||
exportLedger,
|
||||
hasUrgentLedgerContext,
|
||||
LEDGER_EXPORT_VERSION,
|
||||
photoQualityMessage,
|
||||
sanitizeEntry,
|
||||
} from '../src/domain.js';
|
||||
@@ -113,9 +114,10 @@ test('sanitizes a user entry to the MVP data contract', () => {
|
||||
});
|
||||
assert.deepEqual(Object.keys(entry).sort(), [
|
||||
'bristolType', 'color', 'discomfort', 'id', 'note', 'occurredAt',
|
||||
'photoDataUrl', 'symptoms', 'urgency'
|
||||
'photoDataUrl', 'provenance', 'symptoms', 'urgency'
|
||||
].sort());
|
||||
assert.equal(entry.unexpected, undefined);
|
||||
assert.equal(entry.provenance, null, 'a manual entry records no AI provenance rather than an invented one');
|
||||
});
|
||||
|
||||
test('photo quality guidance is deterministic and does not claim visual diagnosis', () => {
|
||||
@@ -125,10 +127,11 @@ test('photo quality guidance is deterministic and does not claim visual diagnosi
|
||||
assert.doesNotMatch(photoQualityMessage({ width: 1200, height: 900, brightness: 0.5 }), /type [1-7]|disease|diagnos/i);
|
||||
});
|
||||
|
||||
test('export ledger is portable JSON with version and entries', () => {
|
||||
test('export ledger is portable JSON with the current version and entries', () => {
|
||||
const text = exportLedger([{ id: 'a', bristolType: 4 }], '2026-08-18T00:00:00.000Z');
|
||||
const parsed = JSON.parse(text);
|
||||
assert.equal(parsed.schemaVersion, 1);
|
||||
assert.equal(parsed.schemaVersion, LEDGER_EXPORT_VERSION);
|
||||
assert.equal(parsed.schemaVersion, 2);
|
||||
assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z');
|
||||
assert.equal(parsed.entries.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { chromium } from 'playwright';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
// Issue #35 — a backup is the person's own record, and it has to keep working as the app's AI
|
||||
// provenance fields evolve. This drives the real app in a browser: a version 1 file is upgraded and
|
||||
// says so, a file from a newer Timmy is refused without touching the ledger, the export carries the
|
||||
// contribution receipts, and a log saved from a confirmed AI suggestion comes back saying that.
|
||||
//
|
||||
// The image used below is the committed synthetic Type 4 fixture; no real photo is involved.
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
|
||||
const suggestionBody = {
|
||||
status: 'suggestion', isStool: true, bristolType: 4, color: 'brown', confidence: 0.83,
|
||||
imageQuality: 'good', observations: 'Smooth, formed appearance.',
|
||||
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
|
||||
};
|
||||
await page.route('**/api/vision-status', route => route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: true, modelSeen: true }),
|
||||
}));
|
||||
await page.route('**/api/analyze', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(suggestionBody) }));
|
||||
await page.route('**/api/agent/status', route => route.fulfill({
|
||||
status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled: true, configured: false, authenticated: false, mode: 'local-fallback' }),
|
||||
}));
|
||||
|
||||
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
const basePath = () => page.evaluate(() => document.querySelector('meta[name="timmy-base-path"]')?.content || '/');
|
||||
const storeKey = async suffix => `timmy:${await basePath()}:${suffix}`;
|
||||
const readStore = async suffix => {
|
||||
const raw = await page.evaluate(key => localStorage.getItem(key), await storeKey(suffix));
|
||||
return raw === null ? null : JSON.parse(raw);
|
||||
};
|
||||
// The privacy view sits behind the journal's settings row, exactly as a person reaches it.
|
||||
const openPrivacy = async () => {
|
||||
await page.locator('[data-view="calendar"]').first().click();
|
||||
await page.locator('.settings-row[data-view="privacy"]').first().click();
|
||||
await page.locator('#export').waitFor();
|
||||
};
|
||||
const importBackup = async (name, text) => {
|
||||
await page.locator('#import').setInputFiles({ name, mimeType: 'application/json', buffer: Buffer.from(text) });
|
||||
await page.waitForTimeout(350);
|
||||
};
|
||||
|
||||
const legacyBackup = JSON.stringify({
|
||||
product: 'Timmy the Talking Turd',
|
||||
schemaVersion: 1,
|
||||
exportedAt: '2026-08-01T09:00:00.000Z',
|
||||
entries: [
|
||||
{ id: 'legacy-1', occurredAt: '2026-08-01T08:00:00.000Z', bristolType: 3, color: 'brown', urgency: 1, discomfort: 0, note: 'synthetic', photoDataUrl: '', symptoms: {} },
|
||||
{ id: 'legacy-2', occurredAt: '2026-08-02T08:00:00.000Z', bristolType: 6, color: 'brown', urgency: 2, discomfort: 1, note: '', photoDataUrl: '', symptoms: {} },
|
||||
],
|
||||
});
|
||||
|
||||
// 1. A version 1 backup is imported, upgraded, and says which version it came from.
|
||||
await openPrivacy();
|
||||
await importBackup('timmy-backup-v1.json', legacyBackup);
|
||||
const upgraded = await readStore('ledger-v1');
|
||||
assert.equal(upgraded.length, 2, 'the version 1 backup must restore both logs');
|
||||
await page.locator('[data-portability-notice]').waitFor();
|
||||
const noticeText = (await page.locator('[data-portability-notice]').innerText()).trim();
|
||||
assert.match(noticeText, /version 1/i, 'the notice must name the version the file came from');
|
||||
assert.match(noticeText, /upgraded/i, 'the notice must say the backup was upgraded');
|
||||
assert.equal(await page.locator('[data-portability-notice]').getAttribute('data-portability-version'), '1');
|
||||
assert.equal(upgraded[0].provenance, null, 'a pre-provenance backup must not acquire a source it never recorded');
|
||||
assert.equal(upgraded[0].bristolType, 3, 'the confirmed form must survive the upgrade');
|
||||
|
||||
// 2. A file from a newer Timmy is refused, and the ledger it would have replaced stays exactly as it was.
|
||||
const before = JSON.stringify(await readStore('ledger-v1'));
|
||||
await importBackup('timmy-backup-v3.json', JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 3, entries: [{ id: 'future', bristolType: 7 }] }));
|
||||
await page.locator('[data-portability-error]').waitFor();
|
||||
const errorText = (await page.locator('[data-portability-error]').innerText()).trim();
|
||||
assert.match(errorText, /newer version of Timmy/i);
|
||||
assert.equal(JSON.stringify(await readStore('ledger-v1')), before, 'a refused import must not change the ledger');
|
||||
|
||||
// 3. A log saved from a confirmed AI suggestion records that provenance, and the export states it.
|
||||
await page.locator('[data-view="home"]').first().click();
|
||||
await page.locator('[data-scan]').first().click();
|
||||
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||
await page.locator('#ai-consent').check();
|
||||
await page.locator('#analyze-photo').click();
|
||||
await page.locator('#use-suggestion').waitFor();
|
||||
await page.locator('#use-suggestion').click();
|
||||
await page.locator('[data-provenance="confirmed"]').waitFor();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#save').click();
|
||||
await page.getByText(/Private log saved|Saved/).waitFor();
|
||||
const savedLedger = await readStore('ledger-v1');
|
||||
const assisted = savedLedger.find(entry => entry.id !== 'legacy-1' && entry.id !== 'legacy-2');
|
||||
assert.ok(assisted, 'the saved log must be in the ledger');
|
||||
assert.equal(assisted.provenance?.source, 'ai-confirmed', 'a confirmed AI suggestion must be recorded on the saved log');
|
||||
assert.equal(assisted.provenance?.suggestedBristolType, 4);
|
||||
assert.equal(assisted.provenance?.model, 'SmolVLM2-2.2B-Instruct');
|
||||
|
||||
// 4. Export carries the current version, the entries, and the contribution receipts — and nothing else.
|
||||
await openPrivacy();
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), page.locator('#export').click()]);
|
||||
const exported = JSON.parse(await readFile(await download.path(), 'utf8'));
|
||||
assert.equal(exported.schemaVersion, 2);
|
||||
assert.equal(exported.entries.length, 3);
|
||||
assert.deepEqual(exported.contributions, [], 'nothing was contributed, so no receipt may appear');
|
||||
assert.equal(exported.entries.find(entry => entry.id === assisted.id).provenance.source, 'ai-confirmed');
|
||||
|
||||
// 5. Round trip: the export restores into a cleared browser with provenance intact.
|
||||
const exportedText = JSON.stringify(exported);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await openPrivacy();
|
||||
await importBackup('timmy-backup-v2.json', exportedText);
|
||||
const roundTripped = await readStore('ledger-v1');
|
||||
assert.equal(roundTripped.length, 3);
|
||||
const restoredAssisted = roundTripped.find(entry => entry.id === assisted.id);
|
||||
assert.deepEqual(restoredAssisted.provenance, assisted.provenance, 'provenance must survive a round trip');
|
||||
await page.locator('[data-portability-notice]').waitFor();
|
||||
assert.match((await page.locator('[data-portability-notice]').innerText()).trim(), /version 2/i);
|
||||
|
||||
assert.deepEqual(errors, [], `browser errors: ${errors.join(' | ')}`);
|
||||
await browser.close();
|
||||
console.log(JSON.stringify({ ok: true, scenes: ['v1-upgrade', 'future-refused', 'provenance-recorded', 'export-v2', 'round-trip'] }));
|
||||
@@ -0,0 +1,149 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { LEDGER_EXPORT_VERSION, LEDGER_IMPORT_LIMITS, exportLedger, exportLedgerBackup, importLedger, importLedgerBackup } from '../src/domain.js';
|
||||
|
||||
// Issue #35 — a backup is the person's own record. Provenance is part of what they own: a log that
|
||||
// was saved from a confirmed or corrected AI suggestion has to come back out of the export saying
|
||||
// exactly that, instead of silently reverting to looking like a manual entry.
|
||||
const confirmedByUser = {
|
||||
id: 'demo-corrected',
|
||||
occurredAt: '2026-08-18T10:00:00.000Z',
|
||||
bristolType: 2,
|
||||
color: 'brown',
|
||||
urgency: 1,
|
||||
discomfort: 0,
|
||||
note: 'synthetic',
|
||||
photoDataUrl: '',
|
||||
symptoms: {},
|
||||
provenance: {
|
||||
source: 'ai-corrected',
|
||||
model: 'SmolVLM2-2.2B-Instruct',
|
||||
suggestedBristolType: 4,
|
||||
suggestedColor: 'brown',
|
||||
confirmedAt: '2026-08-18T09:59:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
test('an export stamps the current schema version and keeps confirmed AI provenance', () => {
|
||||
const parsed = JSON.parse(exportLedger([confirmedByUser], '2026-08-18T11:00:00.000Z'));
|
||||
|
||||
assert.equal(LEDGER_EXPORT_VERSION, 2);
|
||||
assert.equal(parsed.schemaVersion, 2);
|
||||
assert.equal(parsed.entries.length, 1);
|
||||
assert.deepEqual(parsed.entries[0].provenance, confirmedByUser.provenance);
|
||||
assert.equal(parsed.entries[0].bristolType, 2, 'the corrected value stays the saved value');
|
||||
});
|
||||
|
||||
// A version 1 file was written before entries recorded provenance. Importing it must restore the
|
||||
// confirmed values and say plainly that this backup predates provenance, rather than inventing a
|
||||
// source for logs that never had one.
|
||||
const legacyBackup = JSON.stringify({
|
||||
product: 'Timmy the Talking Turd',
|
||||
schemaVersion: 1,
|
||||
exportedAt: '2026-08-01T09:00:00.000Z',
|
||||
entries: [
|
||||
{
|
||||
id: 'legacy-entry',
|
||||
occurredAt: '2026-08-01T08:00:00.000Z',
|
||||
bristolType: 4,
|
||||
color: 'brown',
|
||||
urgency: 2,
|
||||
discomfort: 1,
|
||||
note: '',
|
||||
photoDataUrl: '',
|
||||
symptoms: { blood: false },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test('a version 1 backup imports its confirmed values and reports the upgrade', () => {
|
||||
const result = importLedgerBackup(legacyBackup);
|
||||
|
||||
assert.equal(result.fromVersion, 1);
|
||||
assert.equal(result.migrated, true);
|
||||
assert.equal(result.entries.length, 1);
|
||||
assert.equal(result.entries[0].id, 'legacy-entry');
|
||||
assert.equal(result.entries[0].bristolType, 4);
|
||||
assert.equal(result.entries[0].provenance, null, 'a pre-provenance backup must not be given a source it never recorded');
|
||||
assert.match(result.notices.join(' '), /version 1/i);
|
||||
assert.match(result.notices.join(' '), /upgraded/i);
|
||||
assert.equal(importLedger(legacyBackup).length, 1, 'the plain entry import still works');
|
||||
});
|
||||
|
||||
// A training contribution receipt is user-owned data too. A backup that restored the journal but
|
||||
// dropped the receipts would tell someone their opt-in never happened, so version 2 carries them —
|
||||
// with the withdrawal tombstone intact and nothing else copied out of the device.
|
||||
const withdrawnReceipt = {
|
||||
contributionId: 'contrib-1',
|
||||
entryId: 'demo-corrected',
|
||||
consentVersion: 'training-consent-v1',
|
||||
consentedAt: '2026-08-18T10:05:00.000Z',
|
||||
labels: { bristolType: 2, color: 'brown' },
|
||||
state: 'withdrawn',
|
||||
withdrawnAt: '2026-08-18T10:30:00.000Z',
|
||||
artifacts: [
|
||||
{ artifact: 'raw-image', contributionId: 'contrib-1', state: 'withdrawn' },
|
||||
{ artifact: 'confirmed-label', contributionId: 'contrib-1', state: 'withdrawn' },
|
||||
],
|
||||
// A crafted file must not be able to hand a credential back to the app.
|
||||
agentAccessCode: 'leak-me-please',
|
||||
};
|
||||
|
||||
test('a backup carries contribution receipts and their tombstones without copying secrets', () => {
|
||||
const text = exportLedgerBackup({
|
||||
entries: [{ ...confirmedByUser, agentAccessCode: 'leak-me-please' }],
|
||||
contributions: [withdrawnReceipt],
|
||||
exportedAt: '2026-08-18T12:00:00.000Z',
|
||||
});
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
assert.equal(parsed.schemaVersion, 2);
|
||||
assert.equal(parsed.contributions.length, 1);
|
||||
assert.equal(parsed.contributions[0].state, 'withdrawn');
|
||||
assert.deepEqual(parsed.contributions[0].artifacts, withdrawnReceipt.artifacts);
|
||||
assert.equal(parsed.entries[0].agentAccessCode, undefined);
|
||||
assert.doesNotMatch(text, /leak-me-please/);
|
||||
|
||||
const restored = importLedgerBackup(text);
|
||||
assert.equal(restored.migrated, false);
|
||||
assert.equal(restored.fromVersion, 2);
|
||||
assert.equal(restored.contributions.length, 1);
|
||||
assert.equal(restored.contributions[0].contributionId, 'contrib-1');
|
||||
assert.equal(restored.contributions[0].state, 'withdrawn');
|
||||
assert.deepEqual(restored.contributions[0].artifacts, withdrawnReceipt.artifacts);
|
||||
assert.equal(restored.contributions[0].agentAccessCode, undefined);
|
||||
assert.deepEqual(restored.entries[0].provenance, confirmedByUser.provenance);
|
||||
});
|
||||
|
||||
test('a backup from a newer Timmy is refused with an instruction instead of a guess', () => {
|
||||
const future = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 3, entries: [], contributions: [] });
|
||||
|
||||
assert.throws(() => importLedgerBackup(future), /newer version of Timmy/i);
|
||||
});
|
||||
|
||||
test('a file that is not a readable Timmy backup is refused', () => {
|
||||
assert.throws(() => importLedgerBackup('{not json'), /not readable JSON/i);
|
||||
assert.throws(() => importLedgerBackup(JSON.stringify({ hello: 'world' })), /not a supported Timmy backup/i);
|
||||
assert.throws(() => importLedgerBackup(JSON.stringify({ schemaVersion: 0, entries: [] })), /not a supported Timmy backup/i);
|
||||
});
|
||||
|
||||
test('an oversized backup or one with too many logs is refused before anything is imported', () => {
|
||||
const huge = `{"schemaVersion":2,"entries":[]}${' '.repeat(LEDGER_IMPORT_LIMITS.maxTextLength)}`;
|
||||
assert.throws(() => importLedgerBackup(huge), /too large/i);
|
||||
|
||||
const many = { schemaVersion: 2, entries: Array.from({ length: LEDGER_IMPORT_LIMITS.maxEntries + 1 }, () => ({ id: 'x', bristolType: 4 })) };
|
||||
assert.throws(() => importLedgerBackup(JSON.stringify(many)), /more logs than Timmy will import/i);
|
||||
});
|
||||
|
||||
test('an unreadable contribution receipt is skipped and reported, never silently dropped', () => {
|
||||
const text = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
entries: [{ id: 'kept', bristolType: 4 }],
|
||||
contributions: [{ entryId: 'kept', state: 'queued-local' }],
|
||||
});
|
||||
const result = importLedgerBackup(text);
|
||||
|
||||
assert.equal(result.contributions.length, 0);
|
||||
assert.match(result.notices.join(' '), /could not be read and was skipped/i);
|
||||
});
|
||||
@@ -37,3 +37,16 @@ test('Hermes deployment installs a checked-in non-diagnostic workspace policy',
|
||||
assert.match(policy, /explicit user intent and confirmation/i);
|
||||
assert.match(readme, /install -m 600 docs\/TIMMY-AGENT-POLICY\.md/);
|
||||
});
|
||||
|
||||
test('the decision record keeps a backup versioned and refuses files it cannot read', async () => {
|
||||
const decision = await readFile(decisionPath, 'utf8');
|
||||
const readme = await readFile(readmePath, 'utf8');
|
||||
|
||||
assert.match(decision, /### Backup portability and entry provenance/);
|
||||
assert.match(decision, /schema version/i);
|
||||
assert.match(decision, /upgraded on import/i);
|
||||
assert.match(decision, /newer version is refused/i);
|
||||
assert.match(decision, /a refused import leaves the ledger unchanged/i);
|
||||
assert.match(decision, /provenance is stored on the confirmed log/i);
|
||||
assert.match(readme, /versioned JSON backup/i);
|
||||
});
|
||||
@@ -343,3 +343,33 @@ test('release demo shows the training contribution as a separate opt-in receipt
|
||||
assert.match(builder, /"training_contribution_consent": "passed"/);
|
||||
assert.match(builder, /"test:training-contribution"/);
|
||||
});
|
||||
|
||||
test('release demo shows a versioned backup, an upgraded older file, and a refused newer one', async () => {
|
||||
const demo = await readFile(demoPath, 'utf8');
|
||||
const builder = await readFile(builderPath, 'utf8');
|
||||
const app = await readFile(new URL('../app.js', import.meta.url), 'utf8');
|
||||
|
||||
// Issue #35: the recording must export the real file, import an older version, refuse a newer one,
|
||||
// and reopen a restored log so its provenance is on screen — not merely described.
|
||||
assert.match(demo, /waitForEvent\('download'\)/);
|
||||
assert.match(demo, /exportedBackup\.schemaVersion !== 2/);
|
||||
assert.match(demo, /the backup must carry the withdrawn receipt as a tombstone/);
|
||||
assert.match(demo, /\[data-portability-notice\]\[data-portability-version="1"\]/);
|
||||
assert.match(demo, /\[data-portability-error\]/);
|
||||
assert.match(demo, /\[data-entry-provenance="ai-corrected"\]/);
|
||||
// Every claim is measured before the caption that makes it.
|
||||
assert.match(demo, /an older backup must be reported as upgraded/);
|
||||
assert.match(demo, /a refused import must not change the ledger it would have replaced/);
|
||||
assert.match(demo, /a restored log must still show that it began as a corrected AI suggestion/);
|
||||
// The controls belong to the app, and the provenance line is its own rendering.
|
||||
assert.match(app, /id="export"/);
|
||||
assert.match(app, /data-portability-notice/);
|
||||
assert.match(app, /data-entry-provenance=/);
|
||||
// Captions and the gate key are required, so a later release cannot drop the scenes silently.
|
||||
assert.match(builder, /A backup is versioned, and it carries the withdrawn receipt as a tombstone rather than silence/);
|
||||
assert.match(builder, /An older backup is upgraded on import, and the notice says which version the file came from/);
|
||||
assert.match(builder, /A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched/);
|
||||
assert.match(builder, /A restored log still says it began as an AI suggestion you corrected — provenance is part of the record/);
|
||||
assert.match(builder, /"portable_backup_restore": "passed"/);
|
||||
assert.match(builder, /"test:portability"/);
|
||||
});
|
||||
@@ -44,11 +44,14 @@ async function dispatchFetch(handler, request) {
|
||||
}
|
||||
|
||||
test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => {
|
||||
// The backup/provenance release (issue #35) changes app.js and src/domain.js, so the shell cache
|
||||
// must advance or an installed client keeps serving the old ledger code indefinitely.
|
||||
const { listeners, deleted } = loadWorker({
|
||||
cacheKeys: [
|
||||
'timmy-shell-v4',
|
||||
'timmy-shell:/:v4',
|
||||
'timmy-shell:/:v5',
|
||||
'timmy-shell:/:v6',
|
||||
'timmy-shell:/timmy-staging/:v4',
|
||||
'sibling-shared-origin-cache',
|
||||
],
|
||||
@@ -56,7 +59,7 @@ test('root activation deletes only its obsolete Timmy caches including the legac
|
||||
|
||||
await dispatchExtendable(listeners.get('activate'));
|
||||
|
||||
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']);
|
||||
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4', 'timmy-shell:/:v5']);
|
||||
});
|
||||
|
||||
test('offline shell lookup uses only the current named cache', async () => {
|
||||
|
||||
Reference in new issue
Block a user