import { chromium } from 'playwright'; import assert from 'node:assert/strict'; import { mkdir } from 'node:fs/promises'; // Issue #24 — contributing a confirmed photo to training is a separate opt-in. This runs the real // app in a browser: a journal save must record nothing, the consent sheet must show the version and // the receipt before the button works, and a withdrawal must leave a tombstone naming every derived // artifact. The photo is a synthetic 1x1 PNG constant; no real photo is loaded. const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='; const CONSENT_VERSION = 'training-consent-v1'; 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)); page.on('dialog', dialog => dialog.accept()); await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); const basePath = () => page.evaluate(() => document.querySelector('meta[name="timmy-base-path"]')?.content || '/'); const storeKey = async suffix => `timmy:${await basePath()}:${suffix}`; const readStore = async suffix => { const key = await storeKey(suffix); const raw = await page.evaluate(storeKeys => localStorage.getItem(storeKeys), key); return raw === null ? null : JSON.parse(raw); }; const writeStore = async (suffix, value) => { const key = await storeKey(suffix); await page.evaluate(({ storeKey: k, payload }) => localStorage.setItem(k, payload), { storeKey: key, payload: JSON.stringify(value) }); }; // 1. Saving a log through the app's own flow contributes nothing. await page.locator('[data-log]').first().click(); await page.locator('[data-type="4"]').click(); await page.locator('#next').click(); await page.locator('#note').fill('Synthetic test entry'); await page.locator('#next').click(); await page.locator('#save').click(); await page.getByText(/Private log saved/).waitFor(); assert.equal(await readStore('training-contributions-v1'), null, 'a journal save must not record a training contribution'); // 2. Seed one photo-bearing log and one text-only log for the privacy section. const seeded = [ { id: 'demo-photo', occurredAt: new Date(Date.now() - 86400000).toISOString(), bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'synthetic', photoDataUrl: SYNTHETIC_PHOTO, symptoms: {}, }, { id: 'demo-text', occurredAt: new Date(Date.now() - 2 * 86400000).toISOString(), bristolType: 3, color: 'brown', urgency: 2, discomfort: 1, note: 'no photo here', photoDataUrl: '', symptoms: {}, }, ]; await writeStore('ledger-v1', seeded); await page.reload({ waitUntil: 'networkidle' }); await page.locator('[data-view="calendar"]').first().click(); await page.locator('[data-view="privacy"]').first().click(); await page.getByText(/Your poop\. Your phone\./i).waitFor(); const section = page.locator('[data-training-consent]'); assert.equal(await section.count(), 1, 'the privacy page must offer the training contribution section'); assert.match(await section.innerText(), /off by default/i); assert.match(await section.innerText(), /nothing is uploaded/i); assert.equal((await page.locator('[data-contribution-consent-version]').innerText()).trim(), CONSENT_VERSION); assert.equal((await page.locator('[data-contribution-queued-count]').innerText()).trim(), '0'); assert.equal(await page.locator('[data-contribute-entry="demo-photo"]').count(), 1, 'a saved photo may be contributed'); assert.equal(await page.locator('[data-contribute-entry="demo-text"]').count(), 0, 'a log without photo bytes cannot be contributed'); // 3. The receipt and the consent version are on screen before the confirm control works. await page.locator('[data-contribute-entry="demo-photo"]').click(); const sheet = page.locator('[data-contribution-sheet]'); await sheet.waitFor(); assert.match(await sheet.innerText(), new RegExp(CONSENT_VERSION)); const receiptId = (await page.locator('[data-receipt-id]').innerText()).trim(); assert.ok(receiptId.length > 0, 'the receipt shown before consent must name its own id'); assert.match(await sheet.innerText(), /photo bytes/i); assert.match(await sheet.innerText(), /note text/i); assert.equal(await page.locator('#contribute-confirm').isDisabled(), true, 'the contribution button stays disabled until the terms are accepted'); assert.equal(await readStore('training-contributions-v1'), null, 'no receipt may be written before explicit consent'); await page.screenshot({ path: 'artifacts/training-contribution-consent-mobile.png' }); // 4. Consent records exactly one queued-on-device receipt. await page.locator('#training-consent-check').check(); await page.locator('#contribute-confirm').click(); await page.getByText(/Queued on this device/i).first().waitFor(); const queued = await readStore('training-contributions-v1'); assert.equal(queued.length, 1); assert.equal(queued[0].entryId, 'demo-photo'); assert.equal(queued[0].consentVersion, CONSENT_VERSION); assert.equal(queued[0].contributionId, receiptId); assert.equal(queued[0].state, 'queued-local'); assert.equal(JSON.stringify(queued).includes('data:image'), false, 'the receipt must not copy photo bytes'); assert.equal(JSON.stringify(queued).includes('synthetic'), false, 'the receipt must not copy note text'); await page.locator('[data-contribution-state="queued-local"]').waitFor(); assert.equal((await page.locator('[data-contribution-queued-count]').innerText()).trim(), '1'); await page.screenshot({ path: 'artifacts/training-contribution-queued-mobile.png' }); // 5. Withdrawal leaves a tombstone that addresses every derived artifact by contribution id. await page.locator(`[data-withdraw="${receiptId}"]`).click(); await page.locator('[data-contribution-state="withdrawn"]').waitFor(); const withdrawn = await readStore('training-contributions-v1'); assert.equal(withdrawn.length, 1, 'the withdrawn receipt is kept as a tombstone, not silently erased'); assert.equal(withdrawn[0].state, 'withdrawn'); assert.ok(withdrawn[0].withdrawnAt, 'the tombstone records when the withdrawal happened'); assert.deepEqual(withdrawn[0].artifacts.map(artifact => artifact.artifact).sort(), ['confirmed-label', 'raw-image']); for (const artifact of withdrawn[0].artifacts) { assert.equal(artifact.contributionId, receiptId); assert.equal(artifact.state, 'withdrawn'); } assert.match(await page.locator('[data-withdrawn-artifacts]').innerText(), /raw image/i); assert.equal((await page.locator('[data-contribution-queued-count]').innerText()).trim(), '0'); await page.screenshot({ path: 'artifacts/training-contribution-withdrawn-mobile.png' }); assert.deepEqual(errors, []); await context.close(); await browser.close(); console.log('PASS training contribution stays opt-in, shows its consent version before commit, and withdrawal names every derived artifact');