feat: add private subpage staging slice
All checks were successful
Quality gates / quality (pull_request) Successful in 1m43s
All checks were successful
Quality gates / quality (pull_request) Successful in 1m43s
This commit is contained in:
parent
11d1b364ab
commit
7e11154302
41
app.js
41
app.js
|
|
@ -1,7 +1,21 @@
|
||||||
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js';
|
||||||
import { mergeVisualSuggestion } from './src/analysis.js';
|
import { mergeVisualSuggestion } from './src/analysis.js';
|
||||||
|
|
||||||
const STORE = 'timmy-ledger-v1';
|
const runtimeConfig = window.__TIMMY_CONFIG__ || { basePath: '/', stagingLabel: '' };
|
||||||
|
const BASE_PATH = runtimeConfig.basePath || '/';
|
||||||
|
const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
|
||||||
|
function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
|
||||||
|
const STORE = `timmy:${BASE_PATH}:ledger-v1`;
|
||||||
|
const LEGACY_STORE = 'timmy-ledger-v1';
|
||||||
|
if (BASE_PATH === '/') {
|
||||||
|
const legacyEntries = localStorage.getItem(LEGACY_STORE);
|
||||||
|
if (legacyEntries !== null) {
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem(STORE)) localStorage.setItem(STORE, legacyEntries);
|
||||||
|
localStorage.removeItem(LEGACY_STORE);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
const app = document.querySelector('#app');
|
const app = document.querySelector('#app');
|
||||||
let entries = loadEntries();
|
let entries = loadEntries();
|
||||||
let view = 'home';
|
let view = 'home';
|
||||||
|
|
@ -24,7 +38,8 @@ function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'sh
|
||||||
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
|
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
|
||||||
|
|
||||||
function shell(content) {
|
function shell(content) {
|
||||||
app.innerHTML = `<header class="topbar"><div class="brand"><img src="/assets/timmy.svg" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${nav()}`;
|
const label=runtimeConfig.stagingLabel?`<footer class="staging-label">${esc(runtimeConfig.stagingLabel)}</footer>`:'';
|
||||||
|
app.innerHTML = `<header class="topbar"><div class="brand"><img src="${appPath('assets/timmy.svg')}" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${label}${nav()}`;
|
||||||
bindGlobal();
|
bindGlobal();
|
||||||
}
|
}
|
||||||
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
|
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
|
||||||
|
|
@ -35,7 +50,7 @@ function bindGlobal(){
|
||||||
}
|
}
|
||||||
|
|
||||||
function recentList(limit=5){
|
function recentList(limit=5){
|
||||||
if(!entries.length)return `<div class="empty"><img src="/assets/timmy.svg" alt=""><h3>Quiet bowl, clean slate.</h3><p>Your first log takes about ten seconds.</p></div>`;
|
if(!entries.length)return `<div class="empty"><img src="${appPath('assets/timmy.svg')}" alt=""><h3>Quiet bowl, clean slate.</h3><p>Your first log takes about ten seconds.</p></div>`;
|
||||||
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`<article class="entry"><div class="type-dot">T${e.bristolType}</div><div><strong>${formatDate(e.occurredAt)}</strong><span>${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}</span></div><span class="bucket bucket-${bucketForBristolType(e.bristolType)}">${bucketForBristolType(e.bristolType)}</span></article>`).join('');
|
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`<article class="entry"><div class="type-dot">T${e.bristolType}</div><div><strong>${formatDate(e.occurredAt)}</strong><span>${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}</span></div><span class="bucket bucket-${bucketForBristolType(e.bristolType)}">${bucketForBristolType(e.bristolType)}</span></article>`).join('');
|
||||||
}
|
}
|
||||||
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()<week).length}
|
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()<week).length}
|
||||||
|
|
@ -43,7 +58,7 @@ function currentStreak(){const dates=new Set(entries.map(e=>e.occurredAt.slice(0
|
||||||
|
|
||||||
function home(){
|
function home(){
|
||||||
const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0];
|
const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0];
|
||||||
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section><section class="section"><div class="insight-card"><div class="timmy-orb"><img src="/assets/timmy.svg" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p>${esc(buildTimmySummary(entries))}</p></div></div></section><section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
|
shell(`<main class="home-main"><section class="hero sleek-hero"><span class="eyebrow">Your intelligent pooping pal</span><h1>Log it.<br>Learn the pattern.</h1><p class="lead">Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.</p><button class="capture-cta btn-primary" data-scan><span class="capture-icon" aria-hidden="true">◉</span><span><strong>Start photo log</strong><small>Private, guided, about 10 seconds</small></span><i aria-hidden="true">→</i></button><button class="text-action" data-log>Log manually instead</button></section><section class="glance" aria-label="Journal at a glance"><div><strong>${thisWeek()}</strong><span>this week</span></div><i></i><div><strong>${currentStreak()}</strong><span>day streak</span></div><i></i><div><strong>${entries.length}</strong><span>all logs</span></div></section><section class="section"><div class="insight-card"><div class="timmy-orb"><img src="${appPath('assets/timmy.svg')}" alt=""></div><div><span class="eyebrow">Timmy noticed</span><p>${esc(buildTimmySummary(entries))}</p></div></div></section><section class="section recent-section"><div class="section-head"><div><span class="eyebrow">Latest</span><h2>${latest?'Recent log':'Ready when you are'}</h2></div>${latest?'<button class="text-action compact" data-view="calendar">View journal</button>':''}</div>${latest?`<div class="latest-card"><div class="type-dot">T${latest.bristolType}</div><div><strong>${formatDate(latest.occurredAt)}</strong><span>${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}</span></div><span class="chevron">›</span></div>`:'<p class="fine">One quick, confirmed entry is enough to begin seeing your pattern.</p>'}</section></main>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calendar(){
|
function calendar(){
|
||||||
|
|
@ -69,13 +84,13 @@ function timmy(){
|
||||||
requestAnimationFrame(()=>{const chat=document.querySelector('#chat');if(chat)chat.scrollTop=chat.scrollHeight});
|
requestAnimationFrame(()=>{const chat=document.querySelector('#chat');if(chat)chat.scrollTop=chat.scrollHeight});
|
||||||
}
|
}
|
||||||
async function loadAgentStatus(){
|
async function loadAgentStatus(){
|
||||||
try{const response=await fetch('/api/agent/status',{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
|
try{const response=await fetch(appPath('api/agent/status'),{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
|
||||||
catch{agentStatus={enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
|
catch{agentStatus={enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
|
||||||
if(view==='timmy')timmy();
|
if(view==='timmy')timmy();
|
||||||
}
|
}
|
||||||
async function unlockAgent(){
|
async function unlockAgent(){
|
||||||
const code=document.querySelector('#agent-code')?.value||'';chatError='';
|
const code=document.querySelector('#agent-code')?.value||'';chatError='';
|
||||||
try{const response=await fetch('/api/agent/unlock',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()}
|
try{const response=await fetch(appPath('api/agent/unlock'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()}
|
||||||
catch(error){chatError=error.message||'Could not connect.';timmy()}
|
catch(error){chatError=error.message||'Could not connect.';timmy()}
|
||||||
}
|
}
|
||||||
function localChatReply(message){
|
function localChatReply(message){
|
||||||
|
|
@ -91,7 +106,7 @@ async function sendChat(event){
|
||||||
const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return}
|
const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return}
|
||||||
if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return}
|
if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return}
|
||||||
chatBusy=true;timmy();
|
chatBusy=true;timmy();
|
||||||
try{const response=await fetch('/api/agent/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})}
|
try{const response=await fetch(appPath('api/agent/chat'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})}
|
||||||
catch(error){chatError=error.message||'Hermes is unavailable.';chatMessages.push({role:'timmy',text:'I could not reach Hermes. Your local journal still works, and nothing was changed.'})}
|
catch(error){chatError=error.message||'Hermes is unavailable.';chatMessages.push({role:'timmy',text:'I could not reach Hermes. Your local journal still works, and nothing was changed.'})}
|
||||||
finally{chatBusy=false;timmy()}
|
finally{chatBusy=false;timmy()}
|
||||||
}
|
}
|
||||||
|
|
@ -102,11 +117,11 @@ function privacy(){
|
||||||
}
|
}
|
||||||
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');}
|
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)}}
|
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=[];localStorage.removeItem(STORE);render();toast('Local ledger deleted')}}
|
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
|
||||||
|
|
||||||
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
|
||||||
async function loadVisionStatus(){
|
async function loadVisionStatus(){
|
||||||
try{const response=await fetch('/api/vision-status',{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
|
try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
|
||||||
catch{visionStatus={enabled:false,providerReady:false}}
|
catch{visionStatus={enabled:false,providerReady:false}}
|
||||||
if(document.querySelector('.scan-sheet')&&['pick','ready'].includes(photoFirstMode))showPhotoFirst(photoFirstMode)
|
if(document.querySelector('.scan-sheet')&&['pick','ready'].includes(photoFirstMode))showPhotoFirst(photoFirstMode)
|
||||||
}
|
}
|
||||||
|
|
@ -117,9 +132,9 @@ function visionStatusHtml(){
|
||||||
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
|
return '<div class="model-status offline">○ Vision worker offline · manual logging is still available</div>';
|
||||||
}
|
}
|
||||||
function photoFirstBody(mode,error=''){
|
function photoFirstBody(mode,error=''){
|
||||||
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="/assets/timmy.svg" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div><label class="photo-capture" for="ai-photo"><b>📷</b><strong>Take or choose a photo</strong><span>JPEG, PNG, or WebP · compressed before analysis</span><input id="ai-photo" type="file" accept="image/*" capture="environment"></label><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
|
if(mode==='pick')return `${visionStatusHtml()}<div class="scan-hero"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><h3>One photo. Two useful suggestions.</h3><p>Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.</p></div><label class="photo-capture" for="ai-photo"><b>📷</b><strong>Take or choose a photo</strong><span>JPEG, PNG, or WebP · compressed before analysis</span><input id="ai-photo" type="file" accept="image/*" capture="environment"></label><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Continue without AI</button>`;
|
||||||
if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
|
if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}<img class="photo-preview scan-preview" src="${photoDataUrl}" alt="Photo awaiting AI analysis"><p class="quality-note">${esc(photoHint)}</p><div class="consent-card"><label class="check"><input id="ai-consent" type="checkbox"><span><strong>Send this compressed copy for one-time AI analysis.</strong><br>${processingCopy}</span></label></div><button class="btn btn-primary btn-wide" id="analyze-photo" disabled>Analyze visible form + color</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Use another photo</button>`;}
|
||||||
if(mode==='analyzing')return `<div class="analyzing"><img src="/assets/timmy.svg" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
|
if(mode==='analyzing')return `<div class="analyzing"><img src="${appPath('assets/timmy.svg')}" alt="Timmy"><div class="spinner" aria-hidden="true"></div><h3>Timmy is looking at form and color…</h3><p>Not symptoms. Not disease. Not whether Taco Bell was a strategic error.</p></div>`;
|
||||||
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
if(mode==='error')return `<div class="scan-result needs-input"><b>↻</b><h3>Timmy couldn’t analyze that safely.</h3><p>${esc(error||'Continue manually or try a clearer photo.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Fill it out manually</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||||||
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;
|
if(aiSuggestion?.status==='suggestion')return `<div class="scan-result"><span class="ai-badge">AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE</span><div class="suggestion-pair"><div><small>BRISTOL FORM</small><strong>Type ${aiSuggestion.bristolType}</strong></div><div><small>VISIBLE COLOR</small><strong>${esc(aiSuggestion.color)}</strong></div></div><p>${esc(aiSuggestion.observations||'Visual match found.')}</p><p class="fine">${esc(aiSuggestion.warning)}</p></div><button class="btn btn-primary btn-wide" id="use-suggestion">Use these suggestions</button><button class="btn btn-ghost btn-wide section" id="manual-from-scan">Review everything manually</button>`;
|
||||||
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
return `<div class="scan-result needs-input"><b>?</b><h3>No confident match.</h3><p>${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}</p></div><button class="btn btn-primary btn-wide" id="manual-from-scan">Choose the form yourself</button><button class="btn btn-ghost btn-wide section" id="retake-photo">Try another photo</button>`;
|
||||||
|
|
@ -134,7 +149,7 @@ function showPhotoFirst(mode='pick',error=''){
|
||||||
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)});
|
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)});
|
||||||
}
|
}
|
||||||
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
|
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
|
||||||
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch('/api/analyze',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
|
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
|
||||||
|
|
||||||
function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)}
|
function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)}
|
||||||
function showLogStep(step){
|
function showLogStep(step){
|
||||||
|
|
@ -159,4 +174,4 @@ function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=san
|
||||||
function render(){({home,calendar,timmy,privacy}[view]||home)()}
|
function render(){({home,calendar,timmy,privacy}[view]||home)()}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
if('serviceWorker' in navigator)navigator.serviceWorker.register('/service-worker.js').catch(()=>{});
|
if('serviceWorker' in navigator)navigator.serviceWorker.register(appPath('service-worker.js'),{scope:APP_ROOT}).catch(()=>{});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.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",
|
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.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",
|
||||||
"test:ui": "node tests/ui.acceptance.mjs",
|
"test:ui": "node tests/ui.acceptance.mjs",
|
||||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||||
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
||||||
|
|
|
||||||
49
server.mjs
49
server.mjs
|
|
@ -1,4 +1,5 @@
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import { isIP } from 'node:net';
|
||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import { extname, join, normalize } from 'node:path';
|
import { extname, join, normalize } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
@ -8,46 +9,74 @@ import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig }
|
||||||
|
|
||||||
const root=fileURLToPath(new URL('.',import.meta.url));
|
const root=fileURLToPath(new URL('.',import.meta.url));
|
||||||
const port=Number(process.env.PORT||4173);
|
const port=Number(process.env.PORT||4173);
|
||||||
|
const host=process.env.HOST||'0.0.0.0';
|
||||||
|
if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.');
|
||||||
|
function resolveBasePath(value) {
|
||||||
|
const raw=String(value||'');
|
||||||
|
if(!raw||raw==='/')return '';
|
||||||
|
if(raw.length>128||!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*\/?$/.test(raw))throw new Error('TIMMY_BASE_PATH must be a canonical absolute URL path using plain unreserved characters.');
|
||||||
|
const normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
|
||||||
|
if(normalized.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);
|
||||||
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'};
|
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'};
|
||||||
const visionConfig=resolveVisionConfig(process.env);
|
const visionConfig=resolveVisionConfig(process.env);
|
||||||
const agentConfig=resolveHermesAgentConfig(process.env);
|
const agentConfig=resolveHermesAgentConfig(process.env);
|
||||||
const agentService=createHermesAgentService({config:agentConfig});
|
const agentService=createHermesAgentService({config:agentConfig});
|
||||||
|
const release=/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(process.env.TIMMY_RELEASE_TAG||'')?process.env.TIMMY_RELEASE_TAG:'development';
|
||||||
|
const commit=/^[0-9a-f]{12,40}$/.test(process.env.TIMMY_RELEASE_COMMIT||'')?process.env.TIMMY_RELEASE_COMMIT:'000000000000';
|
||||||
|
const publicBasePath=basePath||'/';
|
||||||
|
const appRoot=basePath?`${basePath}/`:'/';
|
||||||
|
const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:'';
|
||||||
|
|
||||||
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
|
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
|
||||||
function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
|
function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
|
||||||
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
|
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
|
||||||
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
|
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
|
||||||
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
|
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
|
||||||
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${secure}`}
|
function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`}
|
||||||
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
|
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
|
||||||
|
|
||||||
http.createServer(async(req,res)=>{
|
http.createServer(async(req,res)=>{
|
||||||
try{
|
try{
|
||||||
const url=new URL(req.url,'http://localhost');
|
const url=new URL(req.url,'http://localhost');
|
||||||
if(url.pathname==='/api/vision-status'&&req.method==='GET'){
|
if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')}
|
||||||
|
if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()}
|
||||||
|
const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname;
|
||||||
|
if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
|
||||||
|
if(appPath==='/api/vision-status'&&req.method==='GET'){
|
||||||
const provider=await probeVisionProvider(visionConfig);
|
const provider=await probeVisionProvider(visionConfig);
|
||||||
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
|
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
|
||||||
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
|
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
|
||||||
}
|
}
|
||||||
if(url.pathname==='/api/analyze'&&req.method==='POST'){
|
if(appPath==='/api/analyze'&&req.method==='POST'){
|
||||||
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
|
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
|
||||||
const payload=await readJson(req);
|
const payload=await readJson(req);
|
||||||
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
|
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
|
||||||
catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})}
|
catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})}
|
||||||
}
|
}
|
||||||
if(url.pathname==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
|
if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
|
||||||
if(url.pathname==='/api/agent/unlock'&&req.method==='POST'){
|
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
|
||||||
try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)}
|
try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)}
|
||||||
}
|
}
|
||||||
if(url.pathname==='/api/agent/chat'&&req.method==='POST'){
|
if(appPath==='/api/agent/chat'&&req.method==='POST'){
|
||||||
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
|
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
|
||||||
}
|
}
|
||||||
if(url.pathname.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
|
if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
|
||||||
if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()}
|
if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()}
|
||||||
const pathname=decodeURIComponent(url.pathname);
|
const pathname=decodeURIComponent(appPath);
|
||||||
let path=normalize(join(root,pathname==='/'?'index.html':pathname));
|
let path=normalize(join(root,pathname==='/'?'index.html':pathname));
|
||||||
if(!path.startsWith(root))throw new Error('bad path');
|
if(!path.startsWith(root))throw new Error('bad path');
|
||||||
const info=await stat(path);if(info.isDirectory())path=join(path,'index.html');
|
const info=await stat(path);if(info.isDirectory())path=join(path,'index.html');
|
||||||
const body=await readFile(path);res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body);
|
let body=await readFile(path);
|
||||||
|
if(appPath==='/'||appPath==='/index.html'){
|
||||||
|
const browserConfig=JSON.stringify({basePath:publicBasePath,stagingLabel}).replace(/</g,'\\u003c');
|
||||||
|
body=body.toString('utf8').replace(/(["'])\//g,`$1${appRoot}`).replace('<head>',`<head>\n <base href="${appRoot}">`).replace('<script type="module"',`<script>window.__TIMMY_CONFIG__=${browserConfig}</script>\n <script type="module"`);
|
||||||
|
}
|
||||||
|
if(appPath==='/manifest.webmanifest'){
|
||||||
|
const manifest=JSON.parse(body.toString('utf8'));manifest.start_url=appRoot;manifest.scope=appRoot;manifest.icons=manifest.icons.map(icon=>({...icon,src:`${appRoot}${icon.src.replace(/^\//,'')}`}));body=JSON.stringify(manifest);
|
||||||
|
}
|
||||||
|
res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body);
|
||||||
}catch(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')}
|
}catch(error){if(error instanceof AgentGatewayError)return sendAgentError(res,error);res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found')}
|
||||||
}).listen(port,'0.0.0.0',()=>console.log(`Timmy is listening on http://0.0.0.0:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));
|
}).listen(port,host,()=>console.log(`Timmy is listening on http://${host}:${port} · vision ${visionConfig.enabled?'configured':'disabled'} · Hermes ${agentConfig.configured?'locked and ready':'disabled'}`));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,50 @@
|
||||||
const CACHE='timmy-shell-v4';
|
const ROOT = new URL(self.registration.scope).pathname;
|
||||||
const ASSETS=['/','/index.html','/styles.css','/app.js','/src/domain.js','/src/analysis.js','/manifest.webmanifest','/assets/timmy.svg','/assets/icon-192.svg','/assets/icon-512.svg'];
|
const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`;
|
||||||
self.addEventListener('install',event=>event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(ASSETS)).then(()=>self.skipWaiting())));
|
const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`;
|
||||||
self.addEventListener('activate',event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(k=>k!==CACHE).map(k=>caches.delete(k)))).then(()=>self.clients.claim())));
|
const CACHE = `${CACHE_NAMESPACE}v5`;
|
||||||
self.addEventListener('fetch',event=>{if(event.request.method!=='GET')return;event.respondWith(fetch(event.request).then(response=>{const copy=response.clone();caches.open(CACHE).then(cache=>cache.put(event.request,copy));return response}).catch(()=>caches.match(event.request).then(hit=>hit||caches.match('/index.html'))))});
|
const ASSETS = [
|
||||||
|
'',
|
||||||
|
'index.html',
|
||||||
|
'styles.css',
|
||||||
|
'app.js',
|
||||||
|
'src/domain.js',
|
||||||
|
'src/analysis.js',
|
||||||
|
'manifest.webmanifest',
|
||||||
|
'assets/timmy.svg',
|
||||||
|
'assets/icon-192.svg',
|
||||||
|
'assets/icon-512.svg',
|
||||||
|
].map(appPath);
|
||||||
|
|
||||||
|
self.addEventListener('install', event => event.waitUntil(
|
||||||
|
caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting()),
|
||||||
|
));
|
||||||
|
self.addEventListener('activate', event => event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then(keys => Promise.all(keys.filter(key =>
|
||||||
|
(key.startsWith(CACHE_NAMESPACE) && key !== CACHE)
|
||||||
|
|| (ROOT === '/' && key === 'timmy-shell-v4')
|
||||||
|
).map(key => caches.delete(key))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
));
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
if (event.request.method !== 'GET') return;
|
||||||
|
const pathname = new URL(event.request.url).pathname;
|
||||||
|
if (!pathname.startsWith(ROOT)) return;
|
||||||
|
if (pathname.startsWith(appPath('api/'))) {
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then(response => {
|
||||||
|
if (!/\bno-store\b/i.test(response.headers.get('cache-control') || '')) {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(CACHE).then(cache => cache.put(event.request, copy));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.open(CACHE).then(async cache =>
|
||||||
|
(await cache.match(event.request)) || cache.match(appPath('index.html'))
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,6 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t
|
||||||
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}
|
.sheet-backdrop{position:fixed;inset:0;background:rgba(28,22,18,.42);display:flex;align-items:flex-end;justify-content:center;z-index:50;padding-top:28px;backdrop-filter:blur(5px)}.sheet{width:min(100%,680px);max-height:94vh;overflow:auto;background:var(--surface);border-radius:28px 28px 0 0;padding:9px 18px calc(24px + env(safe-area-inset-bottom));box-shadow:0 -15px 50px rgba(30,22,18,.2)}.sheet-handle{width:38px;height:4px;background:#d8d1c8;border-radius:999px;margin:2px auto 15px}.sheet-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.sheet-header h2{margin-top:4px}.icon-btn{width:44px;height:44px;border:0;border-radius:50%;background:#efebe5;font-size:24px;cursor:pointer}.progress{height:4px;background:#eee8df;border-radius:99px;margin:7px 0 18px;overflow:hidden}.progress i{height:100%;display:block;background:var(--teal)}
|
||||||
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:22px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
|
.scan-hero{text-align:center;padding:3px 15px 12px}.scan-hero img{width:76px}.scan-hero h3{font-size:19px;margin:4px 0 7px}.scan-hero p{font-size:13px;line-height:1.45;color:var(--muted)}.model-status{border-radius:14px;padding:9px 11px;font-size:11px;font-weight:700;margin-bottom:12px}.model-status.ready{background:var(--teal-soft);color:var(--teal)}.model-status.offline{background:var(--red-soft);color:var(--red)}.model-status.checking{background:#f0ece5;color:var(--muted)}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:22px;background:#faf7f1;cursor:pointer}.photo-capture b{font-size:30px}.photo-capture strong{margin:6px 0 2px}.photo-capture span{font-size:11px;color:var(--muted)}.photo-capture input{display:none}.photo-preview{display:block;width:100%;max-height:250px;object-fit:cover;border-radius:18px;margin:10px 0}.quality-note{font-size:12px;color:var(--muted)}.consent-card{background:#f5f1e9;border-radius:18px;padding:12px;margin:12px 0}.check{display:flex;gap:11px;align-items:flex-start;padding:9px 0}.check input{width:20px;height:20px;accent-color:var(--teal);flex:0 0 auto}.check span{font-size:13px;line-height:1.4}.analyzing{text-align:center;padding:33px 10px}.analyzing img{width:86px}.spinner{width:32px;height:32px;border:3px solid var(--soft);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}.analyzing p{font-size:13px;color:var(--muted)}.scan-result{background:var(--teal-soft);border-radius:20px;padding:17px;margin-bottom:13px}.scan-result.needs-input{background:#f2eee7;text-align:center}.scan-result.needs-input>b{font-size:28px}.scan-result p{font-size:13px;line-height:1.45;margin:9px 0 0}.ai-badge{font-size:10px;font-weight:850;letter-spacing:.08em;color:var(--teal)}.suggestion-pair{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:12px}.suggestion-pair>div{background:var(--surface);padding:12px;border-radius:14px}.suggestion-pair small,.suggestion-pair strong{display:block}.suggestion-pair small{font-size:9px;color:var(--muted)}.suggestion-pair strong{font-size:18px;margin-top:2px}.ai-prefill{background:var(--teal-soft);border-radius:16px;padding:11px 13px;margin-bottom:12px}.ai-prefill strong,.ai-prefill small{display:block}.ai-prefill small{color:var(--muted);margin-top:3px}.choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.bristol{min-height:66px;border:1px solid var(--line);background:#f7f3ed;border-radius:16px;padding:10px;text-align:left;cursor:pointer}.bristol strong,.bristol span{display:block}.bristol span{font-size:11px;color:var(--muted);margin-top:2px}.bristol.selected{background:var(--ink);color:white}.bristol.selected span{color:#ddd}.field{display:block;margin-bottom:15px}.field-label{display:block;font-size:12px;font-weight:800;margin-bottom:6px}.input{width:100%;min-height:48px;border:1px solid var(--line);border-radius:14px;background:#f8f5ef;padding:10px 12px;color:var(--ink)}textarea.input{min-height:85px;resize:vertical}.range-row{display:grid;grid-template-columns:1fr 37px;gap:10px;align-items:center}.range-row input{accent-color:var(--teal)}.range-val{width:37px;height:37px;border-radius:12px;background:var(--soft);display:grid;place-items:center;font-weight:800}.photo-drop{width:100%;background:#f3eee7}.symptoms{display:flex;flex-direction:column}.alert{background:var(--red-soft);border:1px solid #eec1ba;border-radius:16px;padding:13px;color:#75261f}.alert p{margin:5px 0 0;font-size:13px;line-height:1.4}
|
||||||
.privacy-list{display:flex;flex-direction:column;gap:4px}.privacy-item{display:grid;grid-template-columns:40px 1fr;gap:11px;padding:11px 0;border-bottom:1px solid var(--line)}.privacy-item:last-child{border-bottom:0}.privacy-item>b{width:38px;height:38px;border-radius:13px;background:var(--teal-soft);display:grid;place-items:center;color:var(--teal)}.privacy-item h3{margin-bottom:4px}.privacy-item p,.source-list p{font-size:13px;line-height:1.45;color:var(--muted);margin-bottom:5px}.source-list a{color:var(--teal)}.danger-zone{border-color:#e9c4bf}.toast{position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:var(--ink);color:white;border-radius:999px;padding:11px 16px;font-size:13px;font-weight:700;z-index:100;box-shadow:var(--shadow)}
|
.privacy-list{display:flex;flex-direction:column;gap:4px}.privacy-item{display:grid;grid-template-columns:40px 1fr;gap:11px;padding:11px 0;border-bottom:1px solid var(--line)}.privacy-item:last-child{border-bottom:0}.privacy-item>b{width:38px;height:38px;border-radius:13px;background:var(--teal-soft);display:grid;place-items:center;color:var(--teal)}.privacy-item h3{margin-bottom:4px}.privacy-item p,.source-list p{font-size:13px;line-height:1.45;color:var(--muted);margin-bottom:5px}.source-list a{color:var(--teal)}.danger-zone{border-color:#e9c4bf}.toast{position:fixed;left:50%;bottom:96px;transform:translateX(-50%);background:var(--ink);color:white;border-radius:999px;padding:11px 16px;font-size:13px;font-weight:700;z-index:100;box-shadow:var(--shadow)}
|
||||||
|
.staging-label{margin:8px auto 86px;text-align:center;color:var(--muted);font-size:10px;letter-spacing:.03em;opacity:.72}
|
||||||
@media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}}
|
@media(min-width:560px){.app-shell{padding-inline:24px}.sleek-hero{padding-inline:10px}.choice-grid{grid-template-columns:repeat(3,1fr)}}
|
||||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
|
||||||
|
|
|
||||||
97
tests/service-worker-runtime.test.js
Normal file
97
tests/service-worker-runtime.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const source = await readFile(new URL('../service-worker.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
function loadWorker({ scope = 'https://example.test/', cacheKeys = [], currentHits = new Map(), fetchImpl = async () => { throw new Error('offline'); } } = {}) {
|
||||||
|
const listeners = new Map();
|
||||||
|
const deleted = [];
|
||||||
|
const puts = [];
|
||||||
|
let cacheOpenCount = 0;
|
||||||
|
const currentCache = {
|
||||||
|
addAll: async () => {},
|
||||||
|
put: async request => { puts.push(typeof request === 'string' ? request : request.url); },
|
||||||
|
match: async request => currentHits.get(typeof request === 'string' ? request : request.url),
|
||||||
|
};
|
||||||
|
const caches = {
|
||||||
|
keys: async () => cacheKeys,
|
||||||
|
delete: async key => { deleted.push(key); return true; },
|
||||||
|
open: async () => { cacheOpenCount += 1; return currentCache; },
|
||||||
|
match: async () => { throw new Error('global cache matching must not be used'); },
|
||||||
|
};
|
||||||
|
const self = {
|
||||||
|
registration: { scope },
|
||||||
|
addEventListener: (type, handler) => listeners.set(type, handler),
|
||||||
|
skipWaiting: async () => {},
|
||||||
|
clients: { claim: async () => {} },
|
||||||
|
};
|
||||||
|
vm.runInNewContext(source, { self, caches, fetch: fetchImpl, URL, Promise, String });
|
||||||
|
return { listeners, deleted, puts, getCacheOpenCount: () => cacheOpenCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchExtendable(handler) {
|
||||||
|
let pending;
|
||||||
|
handler({ waitUntil(value) { pending = value; } });
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchFetch(handler, request) {
|
||||||
|
let response;
|
||||||
|
handler({ request, respondWith(value) { response = value; } });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => {
|
||||||
|
const { listeners, deleted } = loadWorker({
|
||||||
|
cacheKeys: [
|
||||||
|
'timmy-shell-v4',
|
||||||
|
'timmy-shell:/:v4',
|
||||||
|
'timmy-shell:/:v5',
|
||||||
|
'timmy-shell:/timmy-staging/:v4',
|
||||||
|
'sibling-shared-origin-cache',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchExtendable(listeners.get('activate'));
|
||||||
|
|
||||||
|
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline shell lookup uses only the current named cache', async () => {
|
||||||
|
const request = { method: 'GET', url: 'https://example.test/app.js' };
|
||||||
|
const current = { body: 'current app' };
|
||||||
|
const { listeners } = loadWorker({ currentHits: new Map([[request.url, current]]) });
|
||||||
|
|
||||||
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||||
|
|
||||||
|
assert.equal(await response, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scoped API GET requests bypass the shell cache entirely', async () => {
|
||||||
|
const request = { method: 'GET', url: 'https://example.test/timmy-staging/api/healthz' };
|
||||||
|
const networkResponse = { headers: { get: () => 'no-store' }, clone: () => ({}) };
|
||||||
|
const { listeners, puts, getCacheOpenCount } = loadWorker({
|
||||||
|
scope: 'https://example.test/timmy-staging/',
|
||||||
|
fetchImpl: async () => networkResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||||
|
|
||||||
|
assert.equal(await response, networkResponse);
|
||||||
|
assert.equal(getCacheOpenCount(), 0);
|
||||||
|
assert.deepEqual(puts, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-store shell responses are returned without being cached', async () => {
|
||||||
|
const request = { method: 'GET', url: 'https://example.test/styles.css' };
|
||||||
|
const networkResponse = { headers: { get: () => 'private, no-store' }, clone: () => ({}) };
|
||||||
|
const { listeners, puts } = loadWorker({ fetchImpl: async () => networkResponse });
|
||||||
|
|
||||||
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
||||||
|
await response;
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(puts, []);
|
||||||
|
});
|
||||||
|
|
@ -3,12 +3,16 @@ import assert from 'node:assert/strict';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
|
||||||
await mkdir('artifacts', { recursive: true });
|
await mkdir('artifacts', { recursive: true });
|
||||||
|
const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173/';
|
||||||
|
const expectedBasePath = new URL(appUrl).pathname.replace(/\/$/, '') || '/';
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
const apiRequests = [];
|
||||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||||
page.on('pageerror', error => errors.push(error.message));
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
page.on('request', request => { if (new URL(request.url()).pathname.includes('/api/')) apiRequests.push(new URL(request.url()).pathname); });
|
||||||
|
|
||||||
await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'SmolVLM2-2.2B-Instruct' }) }));
|
await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'SmolVLM2-2.2B-Instruct' }) }));
|
||||||
await page.route('**/api/agent/status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }) }));
|
await page.route('**/api/agent/status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }) }));
|
||||||
|
|
@ -18,13 +22,18 @@ await page.route('**/api/agent/chat', async route => {
|
||||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4 this week. I can explain the pattern, but I cannot diagnose a cause.' }) });
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4 this week. I can explain the pattern, but I cannot diagnose a cause.' }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('http://127.0.0.1:4173');
|
await page.goto(appUrl);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
assert.equal(await page.evaluate(() => window.__TIMMY_CONFIG__.basePath), expectedBasePath);
|
||||||
assert.equal(await page.locator('.bottom-nav .nav-btn').count(), 3, 'primary navigation must have exactly three destinations');
|
assert.equal(await page.locator('.bottom-nav .nav-btn').count(), 3, 'primary navigation must have exactly three destinations');
|
||||||
assert.equal(await page.locator('main .btn-primary:visible').count(), 1, 'home must expose one dominant primary action');
|
assert.equal(await page.locator('main .btn-primary:visible').count(), 1, 'home must expose one dominant primary action');
|
||||||
assert.equal(await page.locator('[data-log]:visible').count(), 1, 'manual fallback remains available once without competing styling');
|
assert.equal(await page.locator('[data-log]:visible').count(), 1, 'manual fallback remains available once without competing styling');
|
||||||
assert.match(await page.locator('main').innerText(), /photo/i);
|
assert.match(await page.locator('main').innerText(), /photo/i);
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'home must not overflow horizontally');
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'home must not overflow horizontally');
|
||||||
|
if (process.env.TIMMY_EXPECT_STAGING_LABEL) {
|
||||||
|
assert.match(await page.locator('.staging-label').innerText(), /Staging · daily-test · [0-9a-f]{12}/);
|
||||||
|
assert.equal(await page.locator('.staging-label').evaluate(node => node.tagName), 'FOOTER');
|
||||||
|
}
|
||||||
await page.screenshot({ path: 'artifacts/sleek-home-mobile.png', fullPage: true });
|
await page.screenshot({ path: 'artifacts/sleek-home-mobile.png', fullPage: true });
|
||||||
|
|
||||||
await page.locator('[data-view="timmy"]').click();
|
await page.locator('[data-view="timmy"]').click();
|
||||||
|
|
@ -45,6 +54,67 @@ assert.equal(chatRequest, previousRequest, 'urgent language must be intercepted
|
||||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'chat must not overflow horizontally');
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'chat must not overflow horizontally');
|
||||||
await page.screenshot({ path: 'artifacts/sleek-hermes-chat-mobile.png', fullPage: true });
|
await page.screenshot({ path: 'artifacts/sleek-hermes-chat-mobile.png', fullPage: true });
|
||||||
|
|
||||||
|
await page.locator('[data-view="home"]').click();
|
||||||
|
await page.locator('[data-log]').click();
|
||||||
|
await page.locator('#next').click();
|
||||||
|
await page.locator('#next').click();
|
||||||
|
await page.locator('#save').click();
|
||||||
|
assert.deepEqual(await page.evaluate(() => Object.keys(localStorage)), [`timmy:${expectedBasePath}:ledger-v1`]);
|
||||||
|
const serviceWorkerContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||||
|
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
||||||
|
await serviceWorkerPage.goto(new URL('/git', appUrl).toString());
|
||||||
|
await serviceWorkerPage.evaluate(async () => {
|
||||||
|
await caches.open('sibling-shared-origin-cache');
|
||||||
|
await caches.open('timmy-shell-v5:/');
|
||||||
|
});
|
||||||
|
await serviceWorkerPage.goto(appUrl);
|
||||||
|
const registration = await serviceWorkerPage.evaluate(async () => {
|
||||||
|
const ready = await Promise.race([navigator.serviceWorker.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('service worker timeout')), 5000))]);
|
||||||
|
return { scope: ready.scope, scriptURL: ready.active?.scriptURL || '', cacheKeys: await caches.keys() };
|
||||||
|
});
|
||||||
|
assert.ok(registration.cacheKeys.includes('sibling-shared-origin-cache'), 'Timmy service worker must preserve sibling application caches');
|
||||||
|
if (expectedBasePath !== '/') assert.ok(registration.cacheKeys.includes('timmy-shell-v5:/'), 'prefixed Timmy must preserve the root Timmy cache');
|
||||||
|
await serviceWorkerContext.close();
|
||||||
|
const expectedRoot = expectedBasePath === '/' ? '/' : `${expectedBasePath}/`;
|
||||||
|
assert.equal(new URL(registration.scope).pathname, expectedRoot);
|
||||||
|
assert.equal(new URL(registration.scriptURL).pathname, `${expectedRoot}service-worker.js`);
|
||||||
|
assert.ok(apiRequests.length >= 2);
|
||||||
|
assert.ok(apiRequests.every(path => path.startsWith(`${expectedRoot}api/`)), `API request escaped prefix: ${apiRequests.join(', ')}`);
|
||||||
|
|
||||||
|
if (expectedBasePath === '/') {
|
||||||
|
const migrationContext = await browser.newContext({ serviceWorkers: 'block' });
|
||||||
|
await migrationContext.addInitScript(() => {
|
||||||
|
const entry = id => ({
|
||||||
|
id,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
bristolType: 4,
|
||||||
|
color: 'brown',
|
||||||
|
urgency: 0,
|
||||||
|
discomfort: 0,
|
||||||
|
note: '',
|
||||||
|
symptoms: {},
|
||||||
|
});
|
||||||
|
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([entry('current-root-entry')]));
|
||||||
|
localStorage.setItem('timmy-ledger-v1', JSON.stringify([entry('legacy-root-entry')]));
|
||||||
|
localStorage.setItem('unrelated-sibling-data', 'preserve-me');
|
||||||
|
});
|
||||||
|
const migrationPage = await migrationContext.newPage();
|
||||||
|
await migrationPage.goto(appUrl);
|
||||||
|
assert.equal(await migrationPage.locator('.glance div').last().locator('strong').innerText(), '1');
|
||||||
|
assert.match(await migrationPage.evaluate(() => localStorage.getItem('timmy:/:ledger-v1')), /current-root-entry/);
|
||||||
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
|
||||||
|
|
||||||
|
await migrationPage.evaluate(() => localStorage.setItem('timmy-ledger-v1', '[{"id":"resurrection-risk"}]'));
|
||||||
|
await migrationPage.getByRole('button', { name: 'Journal', exact: true }).click();
|
||||||
|
await migrationPage.locator('[data-view="privacy"]').click();
|
||||||
|
migrationPage.once('dialog', dialog => dialog.accept());
|
||||||
|
await migrationPage.locator('#delete-all').click();
|
||||||
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy:/:ledger-v1')), null);
|
||||||
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
|
||||||
|
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('unrelated-sibling-data')), 'preserve-me');
|
||||||
|
await migrationContext.close();
|
||||||
|
}
|
||||||
|
|
||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
await browser.close();
|
await browser.close();
|
||||||
console.log('Sleek shell + Hermes chat mobile acceptance passed.');
|
console.log('Sleek shell + Hermes chat mobile acceptance passed.');
|
||||||
|
|
|
||||||
167
tests/staging-health.test.js
Normal file
167
tests/staging-health.test.js
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { chmod, mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const hermesFixture = fileURLToPath(new URL('./fixtures/fake-hermes.mjs', import.meta.url));
|
||||||
|
let nextPort = 43100;
|
||||||
|
|
||||||
|
async function startServer(t, env = {}) {
|
||||||
|
const port = nextPort++;
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
const child = spawn(process.execPath, ['server.mjs'], {
|
||||||
|
cwd: root,
|
||||||
|
env: { ...process.env, PORT: String(port), ...env, ...(env.TIMMY_PUBLIC_ORIGIN === '__ORIGIN__' ? { TIMMY_PUBLIC_ORIGIN: origin } : {}) },
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
let stdout = '';
|
||||||
|
child.stdout.on('data', chunk => { stdout += chunk; });
|
||||||
|
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||||
|
t.after(() => child.kill('SIGTERM'));
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
const basePath = (env.TIMMY_BASE_PATH || '').replace(/\/$/, '');
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}: ${stderr}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${origin}${basePath}/api/healthz`);
|
||||||
|
if (response.status) return { origin, child, getStdout: () => stdout };
|
||||||
|
} catch {}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 40));
|
||||||
|
}
|
||||||
|
throw new Error(`server did not become ready: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('staging host can bind loopback instead of every network interface', async t => {
|
||||||
|
const { getStdout } = await startServer(t, { HOST: '127.0.0.1', TIMMY_VISION_ENABLED: '0' });
|
||||||
|
assert.match(getStdout(), /http:\/\/127\.0\.0\.1:/);
|
||||||
|
assert.doesNotMatch(getStdout(), /http:\/\/0\.0\.0\.0:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health endpoint exposes only bounded staging identity and feature flags', async t => {
|
||||||
|
const { origin } = await startServer(t, {
|
||||||
|
TIMMY_RELEASE_TAG: 'daily-2026-08-20.3',
|
||||||
|
TIMMY_RELEASE_COMMIT: 'ca31e6d38bec649407f63880504554c59f2878ae',
|
||||||
|
TIMMY_VISION_ENABLED: '0',
|
||||||
|
TIMMY_AGENT_ENABLED: 'false',
|
||||||
|
SECRET_TOKEN: 'must-not-leak',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/healthz`);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(await response.json(), {
|
||||||
|
ok: true,
|
||||||
|
release: 'daily-2026-08-20.3',
|
||||||
|
commit: 'ca31e6d38bec649407f63880504554c59f2878ae',
|
||||||
|
visionEnabled: false,
|
||||||
|
agentEnabled: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual([...response.headers.keys()].filter(name => /token|cookie|session|path|environment|credential/i.test(name)), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('base path contains static files and APIs without capturing sibling routes', async t => {
|
||||||
|
const { origin } = await startServer(t, { TIMMY_BASE_PATH: '/timmy-staging', TIMMY_VISION_ENABLED: '0' });
|
||||||
|
|
||||||
|
const health = await fetch(`${origin}/timmy-staging/api/healthz`);
|
||||||
|
assert.equal(health.status, 200);
|
||||||
|
const page = await fetch(`${origin}/timmy-staging/`);
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
assert.match(await page.text(), /<div id="app"/);
|
||||||
|
assert.equal((await fetch(`${origin}/timmy-staging/app.js`)).status, 200);
|
||||||
|
assert.equal((await fetch(`${origin}/api/healthz`)).status, 404);
|
||||||
|
assert.equal((await fetch(`${origin}/git`)).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefixed document, manifest, and service worker stay inside the app scope', async t => {
|
||||||
|
const { origin } = await startServer(t, {
|
||||||
|
TIMMY_BASE_PATH: '/timmy-staging',
|
||||||
|
TIMMY_RELEASE_TAG: 'daily-2026-08-20.3',
|
||||||
|
TIMMY_RELEASE_COMMIT: 'ca31e6d38bec649407f63880504554c59f2878ae',
|
||||||
|
TIMMY_STAGING_LABEL: 'true',
|
||||||
|
TIMMY_VISION_ENABLED: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirect = await fetch(`${origin}/timmy-staging`, { redirect: 'manual' });
|
||||||
|
assert.equal(redirect.status, 308);
|
||||||
|
assert.equal(redirect.headers.get('location'), '/timmy-staging/');
|
||||||
|
|
||||||
|
const html = await (await fetch(`${origin}/timmy-staging/`)).text();
|
||||||
|
assert.match(html, /<base href="\/timmy-staging\/">/);
|
||||||
|
assert.match(html, /window\.__TIMMY_CONFIG__=/);
|
||||||
|
assert.match(html, /"basePath":"\/timmy-staging"/);
|
||||||
|
assert.match(html, /"stagingLabel":"Staging · daily-2026-08-20\.3 · ca31e6d38bec"/);
|
||||||
|
|
||||||
|
const manifest = await (await fetch(`${origin}/timmy-staging/manifest.webmanifest`)).json();
|
||||||
|
assert.equal(manifest.start_url, '/timmy-staging/');
|
||||||
|
assert.equal(manifest.scope, '/timmy-staging/');
|
||||||
|
assert.ok(manifest.icons.every(icon => icon.src.startsWith('/timmy-staging/')));
|
||||||
|
assert.equal((await fetch(`${origin}/timmy-staging/service-worker.js`)).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent cookie is constrained to the normalized base path', async t => {
|
||||||
|
const workdir = await mkdtemp(join(tmpdir(), 'timmy-staging-cookie-'));
|
||||||
|
await chmod(hermesFixture, 0o700);
|
||||||
|
t.after(() => rm(workdir, { recursive: true, force: true }));
|
||||||
|
const { origin } = await startServer(t, {
|
||||||
|
TIMMY_BASE_PATH: '/timmy-staging/',
|
||||||
|
TIMMY_AGENT_ENABLED: 'true',
|
||||||
|
TIMMY_AGENT_ACCESS_TOKEN: 'integration-access-code-2026',
|
||||||
|
TIMMY_PUBLIC_ORIGIN: '__ORIGIN__',
|
||||||
|
TIMMY_AGENT_WORKDIR: workdir,
|
||||||
|
TIMMY_HERMES_COMMAND: hermesFixture,
|
||||||
|
TIMMY_VISION_ENABLED: '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/timmy-staging/api/agent/unlock`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' },
|
||||||
|
body: JSON.stringify({ accessCode: 'integration-access-code-2026' }),
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(response.headers.get('set-cookie'), /; Path=\/timmy-staging\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed and escaping base paths are rejected at startup', async () => {
|
||||||
|
const invalid = [
|
||||||
|
'timmy-staging',
|
||||||
|
'/../git',
|
||||||
|
'/timmy/./nested',
|
||||||
|
'/%2e%2e/git',
|
||||||
|
'/%252e%252e/git',
|
||||||
|
'/timmy%2Fgit',
|
||||||
|
'/timmy%5cgit',
|
||||||
|
'/timmy%2dstaging',
|
||||||
|
'/timmy\\git',
|
||||||
|
'/timmy?debug=1',
|
||||||
|
'/timmy#fragment',
|
||||||
|
'/timmy%',
|
||||||
|
'/timmy//nested',
|
||||||
|
'/timmy-staging//',
|
||||||
|
'/timmy-staging;Secure',
|
||||||
|
'/timmy-staging"quoted',
|
||||||
|
"/timmy-staging'quoted",
|
||||||
|
'/timmy staging',
|
||||||
|
'/timmy\nstaging',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const value of invalid) {
|
||||||
|
const child = spawn(process.execPath, ['server.mjs'], {
|
||||||
|
cwd: root,
|
||||||
|
env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: value },
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', chunk => { stderr += chunk; });
|
||||||
|
const exitCode = await Promise.race([
|
||||||
|
new Promise(resolve => child.once('exit', resolve)),
|
||||||
|
new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, 800)),
|
||||||
|
]);
|
||||||
|
assert.notEqual(exitCode, 'timeout', `${JSON.stringify(value)} was accepted`);
|
||||||
|
assert.notEqual(exitCode, 0, `${JSON.stringify(value)} exited successfully`);
|
||||||
|
assert.match(stderr, /TIMMY_BASE_PATH/);
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user