timmy-talking-turd/tests/photo-first.acceptance.mjs
Timmy 6f73b8551c
All checks were successful
Quality gates / quality (pull_request) Successful in 4m14s
fix: close second hostile-review round on ledger portability
- collision-safe ID repair: duplicate ids inside stored data are repaired
  deterministically (first keeps id, twins get id#2, id#3, ... scanning past
  owned suffixes); every distinct local record survives, never dropped or
  silently merged; hostile id types (Symbol/BigInt/objects) repair onto
  fresh deterministic ids instead of throwing
- transactional import: parse+merge into a candidate ledger, persist first,
  then commit memory; quota/error rolls back in-memory state and localStorage
  together with explicit user feedback; total 16MiB portability budget
  enforced before mutation on export, import (post-migration expansion), and
  storage writes
- strict Timmy legacy contract for bare top-level arrays: nonempty array of
  plain rows each carrying a nonempty string id and integer Bristol 1-7;
  arbitrary unrelated arrays are rejected wholesale - no invented medical
  defaults from foreign JSON
- canonical raster photo validation: strict JPEG/PNG/WebP grammar, canonical
  base64 (linear scan, no regex on multi-MB strings), atob round-trip decode,
  declared-format magic bytes, 32B-4MiB decoded bounds; mislabeled SVG/HTML
  and noncanonical tiny junk are stripped while genuine photos survive
  byte-for-byte
- migrateStoredLedger: localStorage is validated and migrated before render;
  invalid dates become safe ISO timestamps, duplicate ids repaired, junk rows
  dropped (never fabricated into default records); healthy storage is
  byte-stable and never rewritten
- sanitizeEntry absorbs Symbol/BigInt/hostile dates/throwing toString,
  valueOf, getTime, toJSON without throwing; results stay serializable
- browser regression suite: quota rollback, pre-render migration, array
  rejection, photo contract, duplicate-ID preservation in the real app flow
- staging-health startup-rejection budget anchored to measured server
  cold-start instead of a fixed 800ms (fixes load-sensitive flake)
2026-08-22 23:47:25 +00:00

61 lines
3.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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));
await page.route('**/api/vision-status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: true, modelSeen: true }),
}));
await page.route('**/api/analyze', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
status: 'suggestion', isStool: true, bristolType: 4, color: 'brown', confidence: 0.83,
imageQuality: 'good', observations: 'Smooth, formed appearance.',
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
}),
}));
const appUrl = process.env.TIMMY_TEST_URL || 'http://127.0.0.1:4173';
await page.goto(appUrl, { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
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');
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(), /providers terms/i);
await page.locator('#ai-consent').check();
assert.equal(await page.locator('#analyze-photo').isEnabled(), true);
await page.locator('#analyze-photo').click();
await page.getByText(/83% confidence/i).waitFor();
assert.equal(await page.getByText('Type 4', { exact: true }).isVisible(), true);
assert.equal(await page.getByText('brown', { exact: true }).isVisible(), true);
assert.match(await page.locator('.scan-result .fine').innerText(), /not a diagnosis/i);
await page.screenshot({ path: 'artifacts/photo-first-result-mobile.png', fullPage: false });
await page.locator('#use-suggestion').click();
assert.equal(await page.getByText('AI PREFILLED', { exact: true }).isVisible(), true);
assert.equal(await page.locator('[data-type="4"]').getAttribute('class').then(v => v.includes('selected')), true);
await page.locator('#next').click();
assert.equal(await page.locator('#color').inputValue(), 'brown');
assert.equal(await page.locator('#urgency').inputValue(), '0');
assert.equal(await page.locator('#discomfort').inputValue(), '0');
assert.match(await page.locator('.fine').last().innerText(), /must come from you/i);
await page.screenshot({ path: 'artifacts/photo-first-prefill-mobile.png', fullPage: false });
assert.deepEqual(errors, []);
await browser.close();
console.log('PASS photo → consent → AI suggestion → confirmed visual prefill → nonvisual fields remain user-reported');