a11y: WCAG/mobile fixes and deterministic visual gates #65
|
|
@ -49,8 +49,20 @@ jobs:
|
|||
sleep 1
|
||||
done
|
||||
npm run test:ui
|
||||
npm run test:a11y
|
||||
npm run test:photo
|
||||
npm run test:sleek
|
||||
- name: Upload accessibility screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: a11y-screenshots
|
||||
path: |
|
||||
artifacts/a11y-journal-mobile.png
|
||||
artifacts/a11y-privacy-mobile.png
|
||||
artifacts/a11y-redflag-alert.png
|
||||
artifacts/a11y-zoom-home.png
|
||||
if-no-files-found: ignore
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high
|
||||
- name: Syntax checks
|
||||
|
|
|
|||
5
app.js
5
app.js
|
|
@ -45,7 +45,7 @@ function shell(content) {
|
|||
app.innerHTML = `<header class="topbar"><div class="brand"><img src="${appPath('assets/timmy.svg')}" alt="Timmy mascot"><div class="brand-copy"><strong>Timmy</strong><span>private bowel journal</span></div></div><span class="local-mark" title="Saved locally">● Ledger local</span></header>${content}${label}${nav()}`;
|
||||
bindGlobal();
|
||||
}
|
||||
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home"><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar"><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy"><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
|
||||
function nav(){return `<nav class="bottom-nav" aria-label="Primary"><button class="nav-btn ${view==='home'?'active':''}" data-view="home" aria-label="Today navigation item" ${view==='home'?'aria-current="page"':''}><b aria-hidden="true">⌂</b><span>Today</span></button><button class="nav-btn ${view==='calendar'||view==='privacy'?'active':''}" data-view="calendar" aria-label="Journal navigation item" ${view==='calendar'||view==='privacy'?'aria-current="page"':''}><b aria-hidden="true">▤</b><span>Journal</span></button><button class="nav-btn ${view==='timmy'?'active':''}" data-view="timmy" aria-label="Timmy navigation item" ${view==='timmy'?'aria-current="page"':''}><b aria-hidden="true">✦</b><span>Timmy</span></button></nav>`}
|
||||
function bindGlobal(){
|
||||
document.querySelectorAll('[data-view]').forEach(btn=>btn.onclick=()=>{view=btn.dataset.view;render()});
|
||||
document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger);
|
||||
|
|
@ -158,12 +158,13 @@ function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=nul
|
|||
function showLogStep(step){
|
||||
document.querySelector('.sheet-backdrop')?.remove();
|
||||
const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=`<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="log-title"><div class="sheet-handle"></div><div class="sheet-header"><div><span class="eyebrow">Step ${step} of 3</span><h2 id="log-title">${step===1?'Pick the closest form':step===2?'Add useful context':'Safety check'}</h2></div><button class="icon-btn" id="close-sheet" aria-label="Close">×</button></div><div class="progress"><i class="progress-step-${step}"></i></div>${stepBody(step)}</section>`;document.body.append(wrap);
|
||||
wrap.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();wrap.remove()}});
|
||||
document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()};bindStep(step);
|
||||
}
|
||||
function stepBody(step){
|
||||
if(step===1)return `${aiSuggestion?.status==='suggestion'?`<div class="ai-prefill"><span class="ai-badge">AI PREFILLED</span><strong>Type ${aiSuggestion.bristolType} · ${esc(aiSuggestion.color)}</strong><small>You’re in charge—tap any type to correct it.</small></div>`:'<p class="fine">Choose the closest match yourself. Timmy never treats a suggestion as fact.</p>'}<div class="choice-grid">${[[1,'Hard separate lumps'],[2,'Lumpy sausage'],[3,'Cracked sausage'],[4,'Smooth and soft'],[5,'Soft blobs'],[6,'Mushy pieces'],[7,'Entirely liquid']].map(([n,d])=>`<button class="bristol ${form.bristolType===n?'selected':''}" data-type="${n}"><strong>Type ${n}</strong><span>${d}</span></button>`).join('')}</div><button class="btn btn-primary btn-wide section" id="next">Confirm + add details →</button>`;
|
||||
if(step===2)return `<label class="field"><span class="field-label">Color</span><select class="input" id="color"><option>brown</option><option>green</option><option>yellow</option><option>pale</option><option>red</option><option>black</option></select></label><label class="field"><span class="field-label">Urgency</span><div class="range-row"><input id="urgency" type="range" min="0" max="4" value="${form.urgency}"><output class="range-val">${form.urgency}</output></div></label><label class="field"><span class="field-label">Discomfort</span><div class="range-row"><input id="discomfort" type="range" min="0" max="4" value="${form.discomfort}"><output class="range-val">${form.discomfort}</output></div></label><label class="field"><span class="field-label">Note (optional)</span><textarea class="input" id="note" maxlength="500" placeholder="Meal, medicine, travel, stress…">${esc(form.note)}</textarea></label><label class="photo-drop btn" for="photo">📷 Add a private photo (optional)<input id="photo" type="file" accept="image/*" capture="environment"></label>${photoDataUrl?`<img class="photo-preview" src="${photoDataUrl}" alt="Private entry preview">`:''}${photoHint?`<p class="fine">${esc(photoHint)}</p>`:''}${aiSuggestion?.status==='suggestion'?'<p class="fine">AI suggested only form and visible color. Urgency, discomfort, notes, and symptoms must come from you.</p>':'<p class="fine">Manual-mode photos stay in this browser and receive quality checks only.</p>'}<div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="next">Safety check →</button></div>`;
|
||||
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
|
||||
const urgent=detectUrgentFlags(form.symptoms);return `<p class="fine">Select anything you have now. This is where Timmy stops joking.</p><div class="symptoms">${[['blood','Blood in stool or rectal bleeding'],['blackOrDarkRed','Black or dark-red stool'],['severePain','Severe or constant abdominal pain'],['vomiting','Vomiting'],['fever','Fever'],['cannotPassGas','Unable to pass gas']].map(([k,l])=>`<label class="check"><input type="checkbox" data-symptom="${k}" ${form.symptoms[k]?'checked':''}><span>${l}</span></label>`).join('')}</div><div id="urgent-box" role="alert" aria-live="assertive">${urgent.urgent?alertHtml(urgent.message):''}</div><div class="row section"><button class="btn btn-ghost" id="back">← Back</button><button class="btn btn-primary" id="save">Save private log</button></div><p class="fine">Not medical advice. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.</p>`;
|
||||
}
|
||||
function alertHtml(msg){return `<div class="alert"><strong>Pause and get medical help.</strong><p>${esc(msg)}</p></div>`}
|
||||
function bindStep(step){
|
||||
|
|
|
|||
BIN
artifacts/a11y-journal-mobile.png
Normal file
BIN
artifacts/a11y-journal-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
BIN
artifacts/a11y-privacy-mobile.png
Normal file
BIN
artifacts/a11y-privacy-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
artifacts/a11y-redflag-alert.png
Normal file
BIN
artifacts/a11y-redflag-alert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
artifacts/a11y-zoom-home.png
Normal file
BIN
artifacts/a11y-zoom-home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
|
|
@ -6,6 +6,7 @@
|
|||
"scripts": {
|
||||
"test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js",
|
||||
"test:ui": "node tests/ui.acceptance.mjs",
|
||||
"test:a11y": "node tests/a11y.acceptance.mjs && node tests/a11y-urgent.acceptance.mjs && node tests/a11y-contrast.acceptance.mjs",
|
||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
||||
"test:staging-smoke": "node tests/staging.acceptance.mjs",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
:root{
|
||||
--ink:#211d1a;--muted:#756e68;--paper:#f6f2ea;--surface:#fffdfa;--soft:#eee9df;
|
||||
--ink:#211d1a;--muted:#6a635c;--paper:#f6f2ea;--surface:#fffdfa;--soft:#eee9df;
|
||||
--teal:#0e7771;--teal-soft:#dcefeb;--gold:#efc75e;--brown:#75452f;--red:#ae3a33;
|
||||
--red-soft:#fff0ed;--line:rgba(52,39,31,.1);--shadow:0 18px 45px rgba(58,43,32,.1);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none
|
||||
|
|
|
|||
63
tests/a11y-contrast.acceptance.mjs
Normal file
63
tests/a11y-contrast.acceptance.mjs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// WCAG 2.x contrast math computed from the CSS custom properties themselves.
|
||||
const report = await page.evaluate(() => {
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const channels = hex => [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||
.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
const luminance = hex => { const [r, g, b] = channels(hex); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
||||
const ratio = (fg, bg) => {
|
||||
const [a, b] = [luminance(fg), luminance(bg)].sort((x, y) => y - x);
|
||||
return (a + 0.05) / (b + 0.05);
|
||||
};
|
||||
const pick = name => root.getPropertyValue(name).trim().replace('#', '');
|
||||
const muted = pick('--muted');
|
||||
const paper = pick('--paper');
|
||||
const surface = pick('--surface');
|
||||
const soft = pick('--soft');
|
||||
const teal = pick('--teal');
|
||||
const tealSoft = pick('--teal-soft');
|
||||
return {
|
||||
mutedOnPaper: ratio(muted, paper),
|
||||
mutedOnSurface: ratio(muted, surface),
|
||||
mutedOnSoft: ratio(muted, soft),
|
||||
tealOnTealSoft: ratio(teal, tealSoft),
|
||||
};
|
||||
});
|
||||
|
||||
// Body-size text must reach 4.5:1 on every background it sits on.
|
||||
for (const [pair, value] of Object.entries(report)) {
|
||||
assert.ok(value >= 4.5, `${pair} contrast ${value.toFixed(2)} meets WCAG AA for body text`);
|
||||
}
|
||||
|
||||
// Text-zoom resilience: 200% zoom keeps the primary CTA usable and unclipped.
|
||||
await page.locator('[data-log]').first().click();
|
||||
await page.keyboard.press('Escape').catch(() => {});
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const zoomed = await page.evaluate(() => {
|
||||
document.documentElement.style.fontSize = '200%';
|
||||
const cta = document.querySelector('.capture-cta');
|
||||
const box = cta.getBoundingClientRect();
|
||||
const nav = document.querySelector('.bottom-nav').getBoundingClientRect();
|
||||
return { ctaHeight: box.height, navVisible: nav.bottom <= window.innerHeight && nav.height > 40 };
|
||||
});
|
||||
assert.ok(zoomed.ctaHeight >= 44, `capture CTA stays at least 44px tall at 200% zoom (${zoomed.ctaHeight}px)`);
|
||||
assert.equal(zoomed.navVisible, true, 'bottom navigation stays visible at 200% text zoom');
|
||||
|
||||
await page.screenshot({ path: 'artifacts/a11y-zoom-home.png', fullPage: false });
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close();
|
||||
console.log('PASS contrast tokens meet WCAG AA and 200% text zoom keeps green paths usable');
|
||||
65
tests/a11y-urgent.acceptance.mjs
Normal file
65
tests/a11y-urgent.acceptance.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// Open the manual logger, walk to the safety-check step.
|
||||
await page.locator('[data-log]').first().click();
|
||||
await page.locator('[data-type="6"]').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#next').click();
|
||||
|
||||
// The urgent alert container must be an assertive live region so a screen
|
||||
// reader announces it the moment a red-flag symptom is checked.
|
||||
assert.equal(
|
||||
await page.locator('#urgent-box[role="alert"]').count(), 1,
|
||||
'#urgent-box is an assertive role="alert" live region',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#urgent-box[aria-live="assertive"]').count(), 1,
|
||||
'#urgent-box carries aria-live="alert"',
|
||||
);
|
||||
|
||||
// Check blood → the alert appears and is announced.
|
||||
await page.locator('[data-symptom="blood"]').check();
|
||||
const alertBox = page.locator('#urgent-box');
|
||||
assert.equal(await alertBox.getAttribute('role'), 'alert');
|
||||
assert.match(await alertBox.innerText(), /Pause and get medical help/i);
|
||||
const liveValue = await alertBox.getAttribute('aria-live');
|
||||
assert.equal(liveValue, 'assertive');
|
||||
|
||||
// Dialog semantics: sheet traps description and close button has a name.
|
||||
const sheet = page.locator('.sheet').last();
|
||||
assert.equal(await sheet.getAttribute('role'), 'dialog');
|
||||
assert.equal(await sheet.getAttribute('aria-modal'), 'true');
|
||||
assert.ok(await sheet.getAttribute('aria-labelledby'), 'sheet names itself via aria-labelledby');
|
||||
assert.equal(await page.locator('.icon-btn[aria-label="Close"]').last().isVisible(), true);
|
||||
|
||||
// Escape closes the dialog (keyboard path out of the modal).
|
||||
await page.locator('.sheet').last().click(); // ensure focus is inside the dialog
|
||||
await page.locator('.icon-btn[aria-label="Close"]').last().focus();
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.locator('.sheet-backdrop').count(), 0, 'Escape dismisses the log sheet');
|
||||
|
||||
// Re-open and capture the red-flag alert visual for review.
|
||||
await page.locator('[data-log]').first().click();
|
||||
await page.locator('[data-type="6"]').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('#next').click();
|
||||
await page.locator('[data-symptom="blood"]').check();
|
||||
await page.waitForTimeout(150);
|
||||
await page.screenshot({ path: 'artifacts/a11y-redflag-alert.png', fullPage: true });
|
||||
assert.equal(await page.locator('#urgent-box .alert').isVisible(), true);
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close();
|
||||
console.log('PASS urgent-alert screen-reader announcement + dialog keyboard semantics');
|
||||
71
tests/a11y.acceptance.mjs
Normal file
71
tests/a11y.acceptance.mjs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
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.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// --- Landmarks and semantics ---
|
||||
assert.equal(await page.locator('nav[aria-label="Primary"]').count(), 1, 'primary nav landmark');
|
||||
assert.equal(await page.locator('main').count(), 1, 'exactly one main landmark');
|
||||
const navButtons = page.locator('.bottom-nav .nav-btn');
|
||||
for (let i = 0; i < await navButtons.count(); i++) {
|
||||
const text = (await navButtons.nth(i).locator('span').innerText()).trim();
|
||||
assert.equal(await navButtons.nth(i).getAttribute('aria-label'), `${text} navigation item`, `nav button ${text} exposes its accessible name`);
|
||||
}
|
||||
// Active view is marked for screen readers.
|
||||
await navButtons.first().click(); // Today already active; click Journal
|
||||
await page.locator('[data-view="calendar"]').click();
|
||||
const activeBtn = page.locator('.bottom-nav .nav-btn.active');
|
||||
assert.equal(await activeBtn.getAttribute('aria-current'), 'page', 'active nav button has aria-current="page"');
|
||||
await page.screenshot({ path: 'artifacts/a11y-journal-mobile.png', fullPage: false });
|
||||
|
||||
// --- Focus visibility: keyboard through the journal surface lands on a visible ring ---
|
||||
await page.keyboard.press('Tab'); // first stop should be inside the document with a visible focus style
|
||||
const focused = page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
const style = getComputedStyle(el);
|
||||
return { tag: el.tagName, outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, outlineColor: style.outlineColor };
|
||||
});
|
||||
assert.notEqual((await focused).outlineStyle, 'none', 'focused element shows an outline');
|
||||
assert.notEqual((await focused).outlineWidth, '0px', 'focused element outline is visible');
|
||||
assert.notEqual((await focused).outlineColor, 'transparent', 'focused element outline is not transparent');
|
||||
|
||||
// --- Privacy path: import control is keyboard reachable and labeled ---
|
||||
await page.locator('.settings-row').click();
|
||||
const importControl = page.locator('#import');
|
||||
const importLabel = page.locator('label[for="import"]');
|
||||
assert.equal(await importLabel.count(), 1, 'Import JSON control has a programmatic label');
|
||||
const exportBtn = page.locator('#export');
|
||||
const exportBox = await exportBtn.boundingBox();
|
||||
assert.ok(exportBox.height >= 44 && exportBox.width >= 88, `export button meets touch target size (${exportBox.width}x${exportBox.height})`);
|
||||
const deleteBox = await page.locator('#delete-all').boundingBox();
|
||||
assert.ok(deleteBox.height >= 44, 'delete-all button meets 44px touch target height');
|
||||
await page.screenshot({ path: 'artifacts/a11y-privacy-mobile.png', fullPage: false });
|
||||
|
||||
// --- Reduced motion removes animation ---
|
||||
const reduced = await context.browser().newContext({
|
||||
viewport: { width: 390, height: 844 }, serviceWorkers: 'block',
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
const rpage = await reduced.newPage();
|
||||
await rpage.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||
const animDurationMs = await rpage.evaluate(() => {
|
||||
const value = getComputedStyle(document.querySelector('.nav-btn') || document.body).animationDuration;
|
||||
const parsed = parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed * 1000 : 0;
|
||||
});
|
||||
assert.ok(animDurationMs < 5, `animations collapse under prefers-reduced-motion (got ${animDurationMs}ms)`);
|
||||
await reduced.close();
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close();
|
||||
console.log('PASS accessibility gates: landmarks, labels, focus ring, touch targets, reduced motion, synthetic screenshots');
|
||||
|
|
@ -105,7 +105,7 @@ if (expectedBasePath === '/') {
|
|||
assert.equal(await migrationPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')), null);
|
||||
|
||||
await migrationPage.evaluate(() => localStorage.setItem('timmy-ledger-v1', '[{"id":"resurrection-risk"}]'));
|
||||
await migrationPage.getByRole('button', { name: 'Journal', exact: true }).click();
|
||||
await migrationPage.getByRole('button', { name: 'Journal navigation item', exact: true }).click();
|
||||
await migrationPage.locator('[data-view="privacy"]').click();
|
||||
migrationPage.once('dialog', dialog => dialog.accept());
|
||||
await migrationPage.locator('#delete-all').click();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user