diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index c77cc35..7dd3f93 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -50,6 +50,7 @@ jobs: done npm run test:ui npm run test:photo + npm run test:mobile-capture npm run test:sleek - name: Dependency audit run: npm audit --audit-level=high diff --git a/app.js b/app.js index 1d9c082..55c7a4e 100644 --- a/app.js +++ b/app.js @@ -135,7 +135,7 @@ function visionStatusHtml(){ return '
○ Vision worker offline · manual logging is still available
'; } function photoFirstBody(mode,error=''){ - if(mode==='pick')return `${visionStatusHtml()}
Timmy

One photo. Two useful suggestions.

Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.

`; + if(mode==='pick')return `${visionStatusHtml()}
Timmy

One photo. Two useful suggestions.

Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.

${error?`

${esc(error)}

`:''}
`; 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()}Photo awaiting AI analysis

${esc(photoHint)}

`;} if(mode==='analyzing')return `
Timmy

Timmy is looking at form and color…

Not symptoms. Not disease. Not whether Taco Bell was a strategic error.

`; if(mode==='error')return `

Timmy couldn’t analyze that safely.

${esc(error||'Continue manually or try a clearer photo.')}

`; @@ -145,7 +145,7 @@ function photoFirstBody(mode,error=''){ function showPhotoFirst(mode='pick',error=''){ photoFirstMode=mode; document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()}; - const file=document.querySelector('#ai-photo');if(file)file.onchange=handleAiPhoto; + 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||visionStatus?.providerReady===false;if(analyze)analyze.onclick=runAiAnalysis; document.querySelector('#retake-photo')?.addEventListener('click',()=>{photoDataUrl='';photoHint='';aiSuggestion=null;showPhotoFirst('pick')}); document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{aiSuggestion=null;showLogStep(1)}); diff --git a/artifacts/selfhost-photo-first-mobile.png b/artifacts/selfhost-photo-first-mobile.png index a5970fa..3fd5e63 100644 Binary files a/artifacts/selfhost-photo-first-mobile.png and b/artifacts/selfhost-photo-first-mobile.png differ diff --git a/package.json b/package.json index 31839e0..ffea87f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "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 tests/staging-config.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:sleek": "node tests/sleek-chat.acceptance.mjs", "test:staging-smoke": "node tests/staging.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/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.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", diff --git a/scripts/build_release.py b/scripts/build_release.py index 5d2b391..2ec573c 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -109,6 +109,7 @@ def main() -> int: raise SystemExit("Acceptance server did not become ready") run(["npm", "run", "test:ui"], tree) run(["npm", "run", "test:photo"], tree) + run(["npm", "run", "test:mobile-capture"], tree) run(["npm", "run", "test:sleek"], tree) demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm" demo_env = dict(server_env) diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index 7214b88..e704cb7 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -100,13 +100,35 @@ async function tap(selector, after = 650) { await sleep(after); } +async function indicate(selector) { + const target = page.locator(selector).first(); + const box = await target.boundingBox(); + if (!box) throw new Error(`Missing demo target: ${selector}`); + await page.evaluate(({ x, y }) => { + document.querySelector('#demo-touch')?.remove(); + const ring = document.createElement('div'); + ring.id = 'demo-touch'; + ring.style.left = `${x}px`; + ring.style.top = `${y}px`; + document.body.append(ring); + ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 }); + setTimeout(() => ring.remove(), 550); + }, { x: box.x + box.width / 2, y: box.y + box.height / 2 }); + await sleep(650); +} + await caption(`TIMMY ${version} • FEATURE DEMO`, 1200); await caption('Automated checks replay this synthetic path before review', 1200); await caption('One clear photo action. Manual logging stays one tap away.', 1500); await tap('[data-scan]', 450); await page.getByText(/Self-hosted model ready/i).waitFor(); await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500); -await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); +await indicate('label[for="camera-photo"]'); +await page.locator('#camera-photo').dispatchEvent('cancel'); +await page.getByText(/Camera or photo picker closed/i).waitFor(); +await caption('Camera closed cleanly — gallery and manual logging are still available', 1600); +await indicate('label[for="gallery-photo"]'); +await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await caption('Nothing uploads until explicit consent', 1100); await page.locator('#ai-consent').check(); await tap('#analyze-photo', 450); diff --git a/styles.css b/styles.css index c9cb992..9345d5e 100644 --- a/styles.css +++ b/styles.css @@ -17,7 +17,7 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t .btn{min-height:50px;border:0;border-radius:16px;padding:0 17px;font-weight:800;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px}.btn:active{transform:scale(.98)}.btn:disabled{opacity:.45;cursor:not-allowed}.btn-primary{background:var(--ink);color:#fff}.btn-secondary{background:var(--teal-soft);color:var(--teal)}.btn-ghost{background:transparent;border:1px solid var(--line)}.btn-danger{background:var(--red);color:#fff}.btn-wide{width:100%}.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.fine{font-size:12px;color:var(--muted);line-height:1.45}.empty{text-align:center;padding:22px 10px;color:var(--muted)}.empty img{width:68px}.empty h3{color:var(--ink);margin-top:8px}.empty p{margin-bottom:0} .chat-page{display:flex;flex-direction:column;min-height:calc(100vh - 175px)}.chat-title{padding-bottom:8px}.agent-status{display:flex;align-items:center;gap:11px;padding:11px 13px;background:rgba(255,253,250,.7);border:1px solid var(--line);border-radius:17px;margin-bottom:12px}.agent-status>i{width:10px;height:10px;border-radius:50%;background:#a9a39d;box-shadow:0 0 0 5px rgba(169,163,157,.13)}.agent-status.connected>i{background:#209479;box-shadow:0 0 0 5px rgba(32,148,121,.13)}.agent-status.locked>i{background:#d19b27}.agent-status strong,.agent-status small{display:block}.agent-status strong{font-size:13px}.agent-status small{font-size:11px;color:var(--muted);margin-top:2px}.conversation{background:var(--surface);border:1px solid var(--line);border-radius:23px;padding:13px;box-shadow:0 10px 30px rgba(55,40,31,.05)}.chat{display:flex;flex-direction:column;gap:9px;min-height:195px;max-height:42vh;overflow:auto;padding:4px 1px 14px}.bubble{max-width:86%;padding:11px 13px;border-radius:17px;line-height:1.42;font-size:14px;white-space:pre-wrap}.bubble.timmy{align-self:flex-start;background:#efebe4;border-bottom-left-radius:5px}.bubble.user{align-self:flex-end;background:var(--teal);color:white;border-bottom-right-radius:5px}.thinking{display:flex;gap:4px}.thinking span{width:6px;height:6px;border-radius:50%;background:#8b847d;animation:blink 1s infinite}.thinking span:nth-child(2){animation-delay:.15s}.thinking span:nth-child(3){animation-delay:.3s}@keyframes blink{50%{opacity:.25;transform:translateY(-2px)}}.composer{display:grid;grid-template-columns:1fr 45px;gap:8px;align-items:end;background:#f0ece5;border-radius:18px;padding:6px}.composer textarea{border:0;background:transparent;resize:none;min-height:42px;max-height:110px;padding:10px 9px;outline:0;color:var(--ink)}.composer button{width:44px;height:44px;border:0;border-radius:14px;background:var(--ink);color:white;font-size:22px;cursor:pointer}.composer button:disabled{opacity:.4}.composer-note{font-size:10px;color:var(--muted);margin:7px 5px 0;line-height:1.35}.chat-error{font-size:12px;color:var(--red);margin:0 4px 8px}.safety-line{margin:13px 4px 0;color:var(--muted);font-size:11px;line-height:1.45}.safety-line strong{color:var(--ink)}.unlock-card{background:#fff8e6;border:1px solid #ead9a9;border-radius:20px;padding:14px;margin-bottom:12px}.unlock-card>label{display:block;font-size:12px;font-weight:800;margin-bottom:7px}.unlock-row{display:grid;grid-template-columns:1fr auto;gap:8px}.unlock-card .fine{margin:8px 2px 0} .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)}.progress-step-1{width:33.34%}.progress-step-2{width:66.68%}.progress-step-3{width:100%} -.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)}.capture-choice-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.capture-status{background:var(--red-soft);color:var(--red);border-radius:14px;padding:10px 12px;font-size:12px;line-height:1.4}.photo-capture{display:grid;place-items:center;text-align:center;border:1.5px dashed #b9aea1;border-radius:21px;padding:16px 10px;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)} .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)}} diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index 07fdb83..40dad15 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -20,6 +20,7 @@ test('Gitea CI gates pull requests and main with the reproducible quality suite' assert.match(workflow, /npm test/); assert.match(workflow, /npm run test:ui/); assert.match(workflow, /npm run test:photo/); + assert.match(workflow, /npm run test:mobile-capture/); assert.match(workflow, /npm run test:sleek/); assert.match(workflow, /npm audit --audit-level=high/); assert.match(workflow, /npm run check:syntax/); diff --git a/tests/mobile-capture.acceptance.mjs b/tests/mobile-capture.acceptance.mjs new file mode 100644 index 0000000..6bf4fee --- /dev/null +++ b/tests/mobile-capture.acceptance.mjs @@ -0,0 +1,59 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; + +const browser = await chromium.launch({ headless: true }); +const viewports = [ + { name: '390x844', width: 390, height: 844 }, + { name: 'iPhone 15 class', width: 393, height: 852 }, +]; + +for (const viewport of viewports) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 2, + serviceWorkers: 'block', + }); + const page = await context.newPage(); + let analysisRequests = 0; + await page.route('**/api/vision-status', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, model: 'synthetic-test-model' }), + })); + await page.route('**/api/analyze', route => { + analysisRequests += 1; + return route.abort(); + }); + + await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); + await page.locator('[data-scan]').click(); + + const camera = page.locator('#camera-photo'); + const gallery = page.locator('#gallery-photo'); + assert.equal(await camera.getAttribute('capture'), 'environment', `${viewport.name}: camera input uses the rear camera`); + assert.equal(await gallery.getAttribute('capture'), null, `${viewport.name}: gallery input does not force camera capture`); + assert.equal(await page.getByText('Take photo', { exact: true }).isVisible(), true); + assert.equal(await page.getByText('Choose from gallery', { exact: true }).isVisible(), true); + + await camera.dispatchEvent('cancel'); + assert.match(await page.locator('[role="status"]').innerText(), /camera.*closed|permission.*denied/i); + assert.equal(await page.getByText('Continue without AI', { exact: true }).isVisible(), true); + assert.equal(analysisRequests, 0, `${viewport.name}: cancellation never uploads`); + + await page.locator('#gallery-photo').setInputFiles({ + name: 'corrupt-synthetic.jpg', + 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(); + 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); + assert.equal(analysisRequests, 0, `${viewport.name}: corrupt and unconsented photos never upload`); + + await context.close(); +} + +await browser.close(); +console.log('PASS camera/gallery paths recover from cancellation at 390x844 and iPhone-class viewport without upload'); diff --git a/tests/photo-first.acceptance.mjs b/tests/photo-first.acceptance.mjs index 0850e36..c0d814f 100644 --- a/tests/photo-first.acceptance.mjs +++ b/tests/photo-first.acceptance.mjs @@ -31,7 +31,7 @@ await page.locator('[data-scan]').click(); await page.getByText(/Self-hosted model ready/i).waitFor(); await page.screenshot({ path: 'artifacts/selfhost-photo-first-mobile.png', fullPage: false }); assert.equal(await page.getByText('One photo. Two useful suggestions.').isVisible(), true); -await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); +await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); assert.equal(await page.locator('#analyze-photo').isDisabled(), true); assert.match(await page.locator('.consent-card').innerText(), /self-hosted model server/i); assert.doesNotMatch(await page.locator('.consent-card').innerText(), /provider’s terms/i); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index aa1b87a..c2701f6 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -10,6 +10,9 @@ test('release demo visibly explains the CI-protected browser path without overst assert.match(demo, /Automated checks replay this synthetic path before review/); assert.match(demo, /One clear photo action\. Manual logging stays one tap away\./); + assert.match(demo, /Camera closed cleanly — gallery and manual logging are still available/); + assert.match(demo, /#camera-photo.*dispatchEvent\('cancel'\)/s); + assert.match(demo, /#gallery-photo.*synthetic-type4\.jpg/s); assert.match(demo, /tests\/fixtures\/synthetic-type4\.jpg/); assert.match(demo, /The pinned bootstrap verifies both model files before starting on private loopback/); assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/); @@ -23,6 +26,7 @@ test('release demo visibly explains the CI-protected browser path without overst test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => { const builder = await readFile(builderPath, 'utf8'); assert.match(builder, /"test:sleek"/); + assert.match(builder, /"test:mobile-capture"/); assert.match(builder, /"sleek_hermes_chat_acceptance": "passed"/); assert.match(builder, /Sleek three-destination shell/); assert.match(builder, /"bash", "-n", "scripts\/bootstrap_selfhost_smolvlm\.sh"/);