Selecting a file the browser cannot decode used to render "Timmy couldn't analyze that safely", which described an upload that never happened. It now states that the file isn't a photo Timmy can read and that nothing was uploaded, with manual entry and the picker both one tap away. The state is asserted in a new browser suite that also asks the running server to refuse the same bytes, and the existing mobile capture suite asserts the new copy across both viewports.
71 lines
3.8 KiB
JavaScript
71 lines
3.8 KiB
JavaScript
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('<html><script>alert(1)</script></html>', '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');
|