import { chromium } from 'playwright'; import assert from 'node:assert/strict'; import { mkdir } from 'node:fs/promises'; // Issue #15: readiness must pass only after the pinned model is loaded. A worker that answers // /v1/models is reachable, but reachable is not loaded — the app must not offer an AI control // that fails after the upload. 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 modelLoaded = false; 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: true, modelSeen: modelLoaded, }), })); await page.route('**/api/analyze', route => { analysisRequests += 1; return 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.', }), }); }); await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); // 1. Worker reachable, pinned model not loaded: no AI promise, no dead control, one action to manual. await page.locator('[data-scan]').click(); await page.getByText(/model is not loaded/i).waitFor(); // Visual acceptance: the not-loaded state must be visually distinct from the ready and offline // states, not an unstyled div that reads like either one of them. const loadingStatus = page.locator('.model-status.loading'); assert.equal(await loadingStatus.count(), 1, 'the not-loaded state needs its own status treatment'); assert.equal(await loadingStatus.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(253, 243, 216)'); assert.equal(await page.getByText(/Self-hosted model ready/i).count(), 0, 'an unloaded model must never be reported ready'); assert.equal(await page.locator('#analyze-photo').count(), 0, 'no analyze control before the model is loaded'); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await page.getByText(/Ready for your review/i).waitFor(); assert.equal(await page.getByText(/model is not loaded/i).count() > 0, true, 'the ready step still states why AI is unavailable'); assert.equal(await page.locator('.consent-card').count(), 0, 'no consent is requested while the model is not loaded'); assert.equal(await page.locator('#analyze-photo').count(), 0, 'no dead analyze control on the ready step'); const fallback = page.locator('#manual-from-scan'); assert.equal(await fallback.isVisible(), true); assert.match(await fallback.innerText(), /without AI|manual/i); await fallback.click(); assert.equal(await page.getByText('Pick the closest form').isVisible(), true, 'one action reaches manual Bristol entry'); assert.equal(analysisRequests, 0, 'nothing is uploaded before the pinned model is loaded'); await page.screenshot({ path: 'artifacts/worker-model-loading-mobile.png' }); await page.locator('#close-sheet').click(); // 2. The pinned model loads: the same flow now offers consent and analysis, and analysis works. modelLoaded = true; await page.locator('[data-scan]').click(); await page.getByText(/Self-hosted model ready/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').isVisible(), true, 'consent returns once the model is loaded'); await page.locator('#ai-consent').check(); await page.locator('#analyze-photo').click(); await page.getByText(/83% confidence/i).waitFor(); assert.equal(analysisRequests, 1, 'analysis is offered exactly when the model is loaded'); await page.screenshot({ path: 'artifacts/worker-model-loaded-mobile.png' }); assert.deepEqual(errors, []); await context.close(); await browser.close(); console.log('PASS readiness waits for the loaded pinned model: no AI control before load, full AI path after load');