Quality gates / quality (pull_request) Successful in 3m44s
A journal save now records no training contribution. A contribution is a second consent: the privacy page states consent version training-consent-v1 and the receipt (contribution id, included fields, never-included fields) before the confirm control is enabled, the receipt is queued on this device only and copies no photo bytes, note text, or symptom answers, and withdrawal keeps a tombstone naming every derived artifact by contribution id. Delete-all clears the receipts with the ledger. Closes #24
121 lines
7.1 KiB
JavaScript
121 lines
7.1 KiB
JavaScript
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');
|