${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.
`;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()}
${esc(photoHint)}
`;}
if(mode==='analyzing')return `
Timmy is looking at form and color…
Not symptoms. Not disease. Not whether Taco Bell was a strategic error.
`;
+ // An unreadable file is its own state, not a failed analysis: nothing was sent, nothing was
+ // attempted, and saying "couldn't analyze that safely" here implied an upload that never happened.
+ if(mode==='unreadable')return `
âš
That file isn’t a photo Timmy can read.
Nothing was uploaded. Choose a JPEG, PNG, or WebP photo, take a new one, or fill it out manually.
`;
if(mode==='error')return `
↻
Timmy couldn’t analyze that safely.
${esc(error||'Continue manually or try a clearer photo.')}
${mode==='result'?'Review Timmy’s suggestion':mode==='analyzing'?'Analyzing privately':mode==='unreadable'?'That photo could not be read':'Start with the camera'}
${photoFirstBody(mode,error)}`;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};
document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))});
const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||!visionReadiness(visionStatus).ready;if(analyze)analyze.onclick=runAiAnalysis;
document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')});
document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)});
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)});
}
-async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.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);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{form.photoDataUrl='';photoHint='';showPhotoFirst('unreadable')}}
async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;suggestionState=createSuggestionState(data);showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
function openLogger(){beginDraft();photoHint='';resetSuggestion();showLogStep(1)}
diff --git a/package.json b/package.json
index 8c299df..0c0d950 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,11 @@
"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",
+ "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/image-ingress.test.js tests/rate-limit.test.js tests/http-body.test.js tests/analyze-ingress.acceptance.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",
+ "test:ingress-capture": "node tests/ingress-capture.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:manual-fallback": "node tests/manual-fallback.acceptance.mjs",
"test:provisional": "node tests/provisional-suggestion.acceptance.mjs",
@@ -17,7 +18,7 @@
"test:pattern-summary": "node tests/pattern-summary.acceptance.mjs",
"test:grounded-chat": "node tests/grounded-chat.acceptance.mjs",
"test:photo-lifecycle": "node tests/photo-lifecycle.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 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/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/image-ingress.js && node --check src/rate-limit.js && node --check src/http-body.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 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/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 && node --check tests/ingress-capture.acceptance.mjs && node --check tests/analyze-ingress.acceptance.test.js",
"check:diff": "bash scripts/check_diff.sh",
"start": "node server.mjs",
"test:worker-readiness": "node tests/worker-readiness.acceptance.mjs"
diff --git a/tests/ingress-capture.acceptance.mjs b/tests/ingress-capture.acceptance.mjs
new file mode 100644
index 0000000..1a491d2
--- /dev/null
+++ b/tests/ingress-capture.acceptance.mjs
@@ -0,0 +1,70 @@
+import { chromium } from 'playwright';
+import assert from 'node:assert/strict';
+
+// Issue #16, as a user experiences it: a file that only claims to be a photo never becomes an upload,
+// never reaches the model worker, and still leaves manual logging one tap away. The second half of
+// this suite asks the real running server to refuse the same bytes, so the browser copy and the
+// server boundary are proven in the same run.
+
+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();
+let analysisRequests = 0;
+const analyzePayloads = [];
+await page.route('**/api/vision-status', route => route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, modelSeen: true, model: 'synthetic-test-model' }),
+}));
+await page.route('**/api/analyze', route => {
+ analysisRequests += 1;
+ analyzePayloads.push(route.request().postData() || '');
+ return route.continue();
+});
+
+await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
+await page.locator('[data-scan]').click();
+
+const spoofed = Buffer.from('', 'utf8');
+await page.locator('#gallery-photo').setInputFiles({ name: 'photo.jpg', mimeType: 'image/jpeg', buffer: spoofed });
+
+const refusal = page.locator('[data-capture-error="unreadable"]');
+await refusal.waitFor();
+const copy = await refusal.innerText();
+assert.match(copy, /isn.t a photo Timmy can read/i, 'the refusal names what is wrong with the file');
+assert.match(copy, /Nothing was uploaded/i, 'the refusal states that nothing left the device');
+assert.equal(await page.getByText(/couldn.t analyze that safely/i).count(), 0,
+ 'an unreadable file must not be reported as a failed analysis');
+assert.equal(analysisRequests, 0, 'an unreadable file must never be posted to /api/analyze');
+assert.equal(await page.locator('#manual-from-scan').isVisible(), true, 'manual entry stays one tap away');
+assert.equal(await page.locator('#retake-photo').isVisible(), true, 'the picker stays reachable');
+
+// The same bytes, offered to the running server directly, are refused before any provider work.
+const serverResponse = await page.evaluate(async payload => {
+ const response = await fetch(`${document.querySelector('meta[name="timmy-base-path"]')?.content.replace(/\/$/, '') || ''}/api/analyze`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ return { status: response.status, body: await response.json() };
+}, { consent: true, imageDataUrl: `data:image/jpeg;base64,${spoofed.toString('base64')}` });
+assert.equal(serverResponse.status, 400, `the server must refuse non-image bytes, saw ${serverResponse.status}`);
+assert.match(serverResponse.body.error, /not a JPEG, PNG, or WebP photo/i);
+// Only this deliberate probe reached the endpoint; the app itself never posted the unreadable file.
+assert.equal(analysisRequests, 1, 'only the deliberate server probe may reach /api/analyze so far');
+assert.match(analyzePayloads[0], /"consent":true/);
+
+// A real (synthetic) photo still reaches the consent step, so the gate is a boundary and not a wall.
+await page.locator('#retake-photo').click();
+await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
+await page.locator('#ai-consent').waitFor();
+assert.equal(await page.locator('[data-capture-error="unreadable"]').count(), 0);
+assert.equal(analysisRequests, 1, 'a photo that is only selected is still not uploaded before consent');
+
+await context.close();
+await browser.close();
+console.log('PASS unreadable photo bytes are refused on device and at the server, with manual entry intact');
diff --git a/tests/mobile-capture.acceptance.mjs b/tests/mobile-capture.acceptance.mjs
index d704f29..3123852 100644
--- a/tests/mobile-capture.acceptance.mjs
+++ b/tests/mobile-capture.acceptance.mjs
@@ -47,8 +47,11 @@ for (const viewport of viewports) {
mimeType: 'image/jpeg',
buffer: Buffer.from('not an image'),
});
- assert.match(await page.locator('.scan-result').innerText(), /could not be read/i);
- await page.getByText('Try another photo', { exact: true }).click();
+ assert.match(await page.locator('.scan-result').innerText(), /isn.t a photo Timmy can read/i);
+ // Issue #16: the refusal is its own state and states plainly that nothing left the device, instead
+ // of borrowing the "couldn't analyze that safely" copy from a failed upload.
+ assert.match(await page.locator('.scan-result').innerText(), /Nothing was uploaded/i);
+ await page.getByText('Choose another photo', { exact: true }).click();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
assert.equal(await page.locator('#analyze-photo').isDisabled(), true);
assert.equal(await page.locator('#retake-photo').isVisible(), true);