Files
timmy-talking-turd/tests/manual-fallback.acceptance.mjs
timmy dce668e72b
Quality gates / quality (pull_request) Successful in 1m54s
feat: guarantee manual fallback and in-session draft recovery (#12)
Logging must never depend on AI availability, and an unsaved entry must not
disappear when the user navigates away mid-log.

- add src/vision-readiness.js so the photo flow consumes one readiness contract
- when the worker is offline/disabled/unknown, offer no AI action and no
  analysis-consent prompt, and keep manual Bristol entry one action away
- add src/draft.js and make the draft own the photo so a chosen photo and
  entered context survive a recoverable close or navigation, with a visible
  kept-draft summary and an explicit start over
- drafts stay in session memory only: a photo awaiting consent is never
  persisted, and reload starts clean
- service worker now refuses to cache image bodies in addition to analysis
  requests and no-store responses
- tests: 2 new unit files, 3 service-worker privacy assertions (1 hold-out),
  1 browser acceptance file, release-demo and CI/package assertions
- release demo now visibly replays the vision-offline pass and the retained
  draft with synthetic data and an in-script assertion
2026-10-07 08:12:43 +00:00

79 lines
4.6 KiB
JavaScript

import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
let analysisRequests = 0;
await page.route('**/api/vision-status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: false, modelSeen: false }),
}));
await page.route('**/api/analyze', route => { analysisRequests += 1; return route.abort(); });
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// 1. Provider down: manual Bristol entry must be reachable in one action, with no dead control.
await page.locator('[data-scan]').click();
await page.getByText(/Vision worker offline/i).waitFor();
assert.equal(await page.locator('#analyze-photo').count(), 0, 'offline flow must not offer a dead analyze control');
const pickFallback = page.locator('#manual-from-scan');
assert.equal(await pickFallback.isVisible(), true);
assert.match(await pickFallback.innerText(), /without AI|manual/i);
await pickFallback.click();
assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'one action reaches manual Bristol entry');
assert.equal(analysisRequests, 0, 'the offline path never uploads a photo');
await page.screenshot({ path: 'artifacts/manual-fallback-offline-mobile.png' });
// 2. Provider down after a photo is chosen: the ready step still offers the one-action manual path.
await page.locator('#close-sheet').click();
await page.locator('[data-scan]').click();
await page.getByText(/Vision worker offline/i).waitFor();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.getByText(/Ready for your review/i).waitFor();
assert.equal(await page.locator('.consent-card').count(), 0, 'no consent is requested when the worker cannot analyze');
assert.equal(await page.locator('#analyze-photo').count(), 0, 'no dead analyze control on the ready step');
const readyFallback = page.locator('#manual-from-scan');
assert.equal(await readyFallback.isVisible(), true);
await readyFallback.click();
assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'ready-step offline path is still one action');
assert.equal(analysisRequests, 0, 'a chosen photo is never uploaded when analysis is unavailable');
await page.screenshot({ path: 'artifacts/manual-fallback-ready-offline-mobile.png' });
// 3. Entered context and the chosen photo survive recoverable navigation.
await page.locator('[data-type="6"]').click();
await page.locator('#next').click();
await page.locator('#urgency').fill('3');
await page.locator('#note').fill('After lunch');
await page.locator('#close-sheet').click();
await page.locator('[data-view="calendar"]').first().click();
await page.locator('[data-view="home"]').first().click();
await page.locator('[data-log]').first().click();
assert.match(await page.locator('.draft-resume').innerText(), /draft kept/i);
assert.equal(await page.locator('.draft-resume').innerText().then(t => /Type 6/i.test(t)), true);
assert.equal(await page.locator('[data-type="6"]').getAttribute('class').then(v => v.includes('selected')), true, 'chosen type is restored');
await page.locator('#next').click();
assert.equal(await page.locator('#urgency').inputValue(), '3', 'urgency is restored');
assert.equal(await page.locator('#note').inputValue(), 'After lunch', 'note is restored');
await page.screenshot({ path: 'artifacts/draft-resumed-mobile.png' });
// 4. Start over clears the retained draft so nothing lingers silently.
await page.locator('#discard-draft').click();
assert.equal(await page.locator('.draft-resume').count(), 0);
assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), true, 'start over returns to an empty draft');
assert.equal(await page.locator('#note').count(), 0, 'the draft is back at step 1');
assert.deepEqual(errors, []);
await context.close();
await browser.close();
console.log('PASS offline one-action manual fallback, no dead controls, and resumable draft with explicit start over');