// Harness dla testów frontendu: uruchamia prawdziwy www/script.js w jsdom
// na uproszczonym DOM-ie i symuluje zdarzenia myszy.
//
// Wypisuje na stdout JSON: {"results": [{"id", "name", "ok", "detail"}, ...]}
// Konsumuje go test_record_button.py — nie zmieniaj formatu bez poprawienia testu.

const fs = require('fs');
const path = require('path');
const { JSDOM } = require('jsdom');

const WWW = path.resolve(__dirname, '..', '..', 'www');
const script = fs.readFileSync(path.join(WWW, 'script.js'), 'utf8');

// geometria panelu: cancelZone po lewej, kulka na środku, lockZone po prawej
const RECTS = {
    cancelZone: { left: 20,  top: 500, right: 100, bottom: 580, width: 80, height: 80 },
    lockZone:   { left: 300, top: 500, right: 380, bottom: 580, width: 80, height: 80 },
    recordBtn:  { left: 160, top: 500, right: 240, bottom: 580, width: 80, height: 80 },
};

const CENTER = {
    cancel: [60, 540],
    lock:   [340, 540],
    home:   [200, 540],
    content: [250, 300],   // itemB
    itemA:   [100, 200],
    itemB:   [300, 200],
};

const XPATH = { itemA: '//*[@id="itemA"]', itemB: '//*[@id="itemB"]' };

// adres z PIN-u — pin-gate.js składa go po wpisaniu PIN-u, script.js tylko z niego
// korzysta, więc tutaj wystarczy dowolna atrapa
const UPLOAD_URL = 'https://test.invalid/backendSrv02/1234';

// sentinel odwiedzin ekranu — musi być ten sam co w www/script.js i user_selection.py
const SCROLLED_XPATH = '__scrolled__';

// pola wysłane w ostatnim żądaniu; xpath, xpath_hash i xpath_time lecą równolegle
const sentField = (log, key) => (log.forms.at(-1) ?? []).filter(([k]) => k === key).map(([, v]) => v);

// Trzy listy sklejone w pozycje. Jedną trójką jadą dwa rodzaje zdarzeń, więc testy
// wskazań patrzą tylko na kliknięcia, a testy ścieżki tylko na odwiedziny ekranu.
const sentEntries = (log) => {
    const hashes = sentField(log, 'xpath_hash');
    const times = sentField(log, 'xpath_time');
    return sentField(log, 'xpath').map((xpath, i) => ({ xpath, hash: hashes[i], time: times[i] }));
};
const sentClicks = (log) => sentEntries(log).filter((e) => e.xpath !== SCROLLED_XPATH);
const sentVisits = (log) => sentEntries(log).filter((e) => e.xpath === SCROLLED_XPATH);
const sentXPaths = (log) => sentClicks(log).map((e) => e.xpath);
const sentHashes = (log) => sentClicks(log).map((e) => e.hash);
const sentTimes = (log) => sentClicks(log).map((e) => e.time);

async function makeDom() {
    const dom = new JSDOM(`<!DOCTYPE html><body>
        <main id="mainContent">
            <p id="itemA">jeden</p>
            <p id="itemB">dwa</p>
        </main>
        <footer>
            <div id="cancelZone">x</div>
            <div><button id="recordBtn"></button></div>
            <div id="lockZone">lock</div>
        </footer>
    </body>`, { runScripts: 'outside-only', pretendToBeVisual: true });

    const { window } = dom;
    const log = { started: 0, stopped: 0, tracksStopped: 0, sent: 0, forms: [], urls: [] };

    // kulka raportuje swoją bieżącą pozycję inline, pozostałe elementy stałą
    for (const [id, rect] of Object.entries(RECTS)) {
        const el = window.document.getElementById(id);
        el.getBoundingClientRect = () => {
            if (id === 'recordBtn' && el.style.position === 'fixed') {
                const left = parseFloat(el.style.left);
                const top = parseFloat(el.style.top);
                return { left, top, right: left + 80, bottom: top + 80, width: 80, height: 80 };
            }
            return rect;
        };
    }
    // treść zajmuje górę ekranu: lewa połowa to itemA, prawa itemB, panel dolny to nie treść
    window.document.elementFromPoint = (x, y) => {
        if (y >= 450) return null;
        return window.document.getElementById(x < 200 ? 'itemA' : 'itemB');
    };

    window.MediaRecorder = class {
        constructor() {
            this.state = 'inactive';
            this.stream = { getTracks: () => [{ stop: () => log.tracksStopped++ }] };
        }
        start() { this.state = 'recording'; log.started++; this.onstart?.(); }
        stop() {
            this.state = 'inactive';
            log.stopped++;
            // w przeglądarce zdarzenie stop leci asynchronicznie — kod produkcyjny
            // dolicza zaznaczenia już po wywołaniu stop(), więc harness musi to odwzorować
            setTimeout(() => this.onstop?.(), 0);
        }
    };
    window.navigator.mediaDevices = { getUserMedia: async () => ({ getTracks: () => [] }) };
    window.Blob = class {};
    window.FormData = class {
        constructor() { this.entries = []; }
        append(key, value) { this.entries.push([key, value]); }
    };
    window.fetch = async (url, options) => {
        log.sent++;
        log.urls.push(url);
        log.forms.push(options?.body?.entries ?? []);
        return {};
    };
    window.Element.prototype.animate = function () {
        const anim = {};
        queueMicrotask(() => anim.onfinish?.());
        return anim;
    };

    // zegar sterowany z testu: script.js liczy momenty wskazania z performance.now()
    let clock = 0;
    window.performance.now = () => clock;

    // karuzela mieszka w index.html, nie w script.js — tu wystarczy jej kontrakt:
    // hash żywej strony, flaga świeżego przewinięcia i zapis na zmianę strony
    const liveListeners = [];
    const carousel = {
        currentHash: 'hash-A',
        justSwiped: false,
        onLiveChange: (listener) => liveListeners.push(listener),
    };
    window.pageCarousel = carousel;

    // brama PIN-u też mieszka poza script.js; tu liczy się tylko adres wysyłki
    window.pinEndpoints = { pin: '1234', uploadUrl: UPLOAD_URL, socketUrl: 'wss://test.invalid/gui-socket/1234' };

    // jsdom potrafi odpalić DOMContentLoaded sam, już po eval. Bez tego licznika
    // script.js inicjalizuje się dwa razy i drugi komplet handlerów psuje wyniki.
    let fired = 0;
    window.addEventListener('DOMContentLoaded', () => fired++);

    dom.window.eval(script);
    await tick();

    if (fired === 0) {
        window.document.dispatchEvent(new window.Event('DOMContentLoaded', { bubbles: true }));
    }
    if (fired > 1) {
        throw new Error(`DOMContentLoaded odpalony ${fired} razy — podwójna inicjalizacja`);
    }

    return {
        window,
        log,
        carousel,
        advance: (ms) => { clock += ms; },
        // przewinięcie karuzeli: nowa żywa strona + powiadomienie, tak jak robi to
        // showLive() w page-carousel.js
        swipeTo: (hash) => {
            carousel.currentHash = hash;
            liveListeners.forEach((listener) => listener(hash));
        },
        // zmiana strony zgłoszona przez karuzelę bez ruchu użytkownika (np. podmianka)
        notifyLive: (hash) => liveListeners.forEach((listener) => listener(hash)),
        ...helpers(window),
    };
}

const tick = () => new Promise(r => setTimeout(r, 0));

function helpers(w) {
    const btn = w.document.getElementById('recordBtn');
    return {
        btn,
        cancelZone: w.document.getElementById('cancelZone'),
        lockZone: w.document.getElementById('lockZone'),
        down: () => btn.dispatchEvent(new w.MouseEvent('mousedown', { bubbles: true })),
        move: ([x, y]) => w.document.dispatchEvent(new w.MouseEvent('mousemove', { bubbles: true, clientX: x, clientY: y })),
        up: () => w.document.dispatchEvent(new w.MouseEvent('mouseup', { bubbles: true })),
        // kliknięcie w element pod punktem — jak tapnięcie w treść
        click: ([x, y]) => {
            const target = w.document.elementFromPoint(x, y) ?? w.document.body;
            target.dispatchEvent(new w.MouseEvent('click', { bubbles: true, clientX: x, clientY: y }));
        },
        clickOn: (el, [x, y]) => {
            el.dispatchEvent(new w.MouseEvent('click', { bubbles: true, clientX: x, clientY: y }));
        },
    };
}

// ---------------------------------------------------------------- przypadki

const results = [];

function check(id, name, ok, detail = '') {
    results.push({ id, name, ok: !!ok, detail: String(detail) });
}

async function scenarioLock() {
    const d = await makeDom();

    d.down(); await tick();
    check('lock_starts_recording', 'wciśnięcie startuje nagrywanie', d.log.started === 1, `started=${d.log.started}`);

    d.move(CENTER.lock);
    check('lock_zone_highlighted', 'najechanie podświetla strefę blokady', d.lockZone.classList.contains('is-over'));

    d.up(); await tick();
    check('lock_keeps_recording', 'puszczenie nad strefą nie kończy nagrania', d.log.stopped === 0, `stopped=${d.log.stopped}`);
    check('lock_sends_nothing', 'blokada nie wysyła nagrania', d.log.sent === 0, `sent=${d.log.sent}`);
    check('lock_button_class', 'przycisk dostaje klasę locked', d.btn.classList.contains('locked'));
    check('lock_button_parked', 'kulka parkuje w strefie blokady',
          d.btn.style.left === '300px' && d.btn.style.top === '500px', `left=${d.btn.style.left} top=${d.btn.style.top}`);
    check('lock_zones_visible', 'strefa anulowania zostaje dostępna',
          d.cancelZone.style.visibility === 'visible', `visibility=${d.cancelZone.style.visibility}`);

    d.move([500, 300]);
    check('lock_button_stays', 'kulka nie goni kursora po zablokowaniu',
          d.btn.style.left === '300px', `left=${d.btn.style.left}`);

    // zakończenie klikiem w zablokowaną kulkę
    d.down(); await tick();
    check('click_no_second_recording', 'klik nie startuje drugiego nagrania', d.log.started === 1, `started=${d.log.started}`);
    check('regrab_stops_pulsing', 'ponowne złapanie gasi pulsowanie', !d.btn.classList.contains('locked'));

    d.up(); await tick();
    check('click_stops', 'klik kończy nagranie', d.log.stopped === 1, `stopped=${d.log.stopped}`);
    check('click_sends', 'klik wysyła nagranie', d.log.sent === 1, `sent=${d.log.sent}`);
    check('click_clears_class', 'klasa locked zdjęta', !d.btn.classList.contains('locked'));
    check('click_resets_position', 'kulka wraca na miejsce',
          d.btn.style.left === '' && d.btn.style.position === '', `left=${d.btn.style.left} pos=${d.btn.style.position}`);
}

async function scenarioDragBackHome() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();
    check('drag_locked', 'nagranie zablokowane', d.log.stopped === 0, `stopped=${d.log.stopped}`);

    d.down();
    check('drag_regrab_stops_pulsing', 'złapanie do przeciągnięcia gasi pulsowanie',
          !d.btn.classList.contains('locked'));

    d.move(CENTER.home);
    check('drag_snaps_home', 'kulka wraca do martwej strefy',
          d.btn.style.position === '', `pos=${d.btn.style.position}`);

    d.up(); await tick();
    check('drag_home_stops_and_sends', 'przeciągnięcie na miejsce kończy i wysyła',
          d.log.stopped === 1 && d.log.sent === 1, `stopped=${d.log.stopped} sent=${d.log.sent}`);
}

async function scenarioCancelWhileLocked() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();

    d.down();
    d.move(CENTER.cancel);
    check('locked_cancel_highlighted', 'strefa anulowania podświetlona', d.cancelZone.classList.contains('is-over'));

    d.up(); await tick();
    check('locked_cancel_stops', 'anulowanie zatrzymuje nagranie', d.log.stopped === 1, `stopped=${d.log.stopped}`);
    check('locked_cancel_no_send', 'anulowanie nic nie wysyła', d.log.sent === 0, `sent=${d.log.sent}`);
}

async function scenarioPlainFlow() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.content);
    d.up(); await tick();
    check('plain_sends', 'zwykłe nagranie wysyła się jak dotąd',
          d.log.stopped === 1 && d.log.sent === 1, `stopped=${d.log.stopped} sent=${d.log.sent}`);
    check('plain_no_lock', 'zwykły przepływ nie wchodzi w blokadę', !d.btn.classList.contains('locked'));
}

async function scenarioNoLeftoverHighlight() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.cancel); d.up(); await tick();
    check('cancel_plain', 'anulowanie bez blokady działa',
          d.log.stopped === 1 && d.log.sent === 0, `stopped=${d.log.stopped} sent=${d.log.sent}`);

    d.down(); await tick();
    check('cancel_highlight_cleared', 'strefa anulowania nie startuje podświetlona',
          !d.cancelZone.classList.contains('is-over'));
    check('lock_highlight_cleared', 'strefa blokady nie startuje podświetlona',
          !d.lockZone.classList.contains('is-over'));
}

async function scenarioStrayClick() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();

    d.up(); await tick();   // puszczenie przycisku myszy poza kulką
    check('stray_click_ignored', 'obcy klik nie kończy zablokowanego nagrania',
          d.log.stopped === 0, `stopped=${d.log.stopped}`);
}

async function scenarioMultiSelect() {
    const d = await makeDom();

    // klik przed nagraniem nic nie zaznacza
    d.click(CENTER.itemA);

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();       // blokada

    d.click(CENTER.itemA);
    d.click(CENTER.itemB);

    // powrót do tego samego elementu to osobna pozycja, nie duplikat do zwinięcia
    d.click(CENTER.itemA);

    // kliknięcia w sterowanie nie zaznaczają
    d.clickOn(d.lockZone, CENTER.lock);
    d.clickOn(d.cancelZone, CENTER.cancel);

    // zakończenie klikiem w kulkę
    d.down(); await tick();
    d.up(); await tick();

    const sent = sentXPaths(d.log);
    check('multi_sent_every_click', 'wysłano każde kliknięcie',
          sent.length === 3, `xpath=${JSON.stringify(sent)}`);
    check('multi_order_kept', 'kolejność wskazania zachowana',
          sent[0] === XPATH.itemA && sent[1] === XPATH.itemB && sent[2] === XPATH.itemA,
          `xpath=${JSON.stringify(sent)}`);
    check('multi_fields_parallel', 'xpath, hash i czas lecą równolegle',
          sentHashes(d.log).length === 3 && sentTimes(d.log).length === 3,
          `hash=${JSON.stringify(sentHashes(d.log))} time=${JSON.stringify(sentTimes(d.log))}`);
}

async function scenarioSelectionTimes() {
    const d = await makeDom();

    d.down(); await tick();                          // start nagrania = zero osi czasu
    d.move(CENTER.lock); d.up(); await tick();       // blokada

    d.advance(1230);
    d.click(CENTER.itemA);
    d.advance(2000);
    d.click(CENTER.itemB);
    d.advance(4400);
    d.click(CENTER.itemA);                            // powrót do pierwszego elementu

    d.down(); await tick(); d.up(); await tick();

    const times = sentTimes(d.log);
    check('times_one_per_click', 'każde kliknięcie ma własny moment',
          times.length === 3, `times=${JSON.stringify(times)}`);
    check('times_from_recording_start', 'momenty liczone od startu nagrania, w dziesiątkach sekundy',
          times[0] === '1.2' && times[1] === '3.2' && times[2] === '7.6', `times=${JSON.stringify(times)}`);
}

async function scenarioDropHasTime() {
    const d = await makeDom();

    d.down(); await tick();
    d.advance(800);
    d.move(CENTER.content);
    d.up(); await tick();

    check('drop_has_time', 'upuszczenie kulki też dostaje moment wskazania',
          sentTimes(d.log)[0] === '0.8', `times=${JSON.stringify(sentTimes(d.log))}`);
}

async function scenarioMultiScreen() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();

    d.advance(1000);
    d.click(CENTER.itemA);            // ekran pierwszy

    d.swipeTo('hash-B');              // karuzela przewinięta z zadokowaną kulką
    d.advance(3000);
    d.click(CENTER.itemB);            // ekran drugi

    d.down(); await tick(); d.up(); await tick();

    const hashes = sentHashes(d.log);
    check('screens_hash_per_click', 'każde kliknięcie ma hash swojego ekranu',
          hashes.length === 2 && hashes[0] === 'hash-A' && hashes[1] === 'hash-B',
          `hash=${JSON.stringify(hashes)}`);
    check('screens_share_one_time_axis', 'momenty z obu ekranów liczone od jednego zera',
          sentTimes(d.log).join(',') === '1.0,4.0', `times=${JSON.stringify(sentTimes(d.log))}`);
}

async function scenarioSwipeDoesNotSelect() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();

    // przeciągnięcie karuzeli myszą kończy się zdarzeniem click — zmiana ekranu nie zaznacza
    d.carousel.justSwiped = true;
    d.click(CENTER.itemA);
    d.carousel.justSwiped = false;

    d.click(CENTER.itemB);            // zwykłe kliknięcie po przewinięciu już zaznacza

    d.down(); await tick(); d.up(); await tick();

    const sent = sentXPaths(d.log);
    check('swipe_click_ignored', 'klik kończący przewinięcie karuzeli nie zaznacza',
          sent.length === 1 && sent[0] === XPATH.itemB, `xpath=${JSON.stringify(sent)}`);
}

async function scenarioSelectionResets() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();
    d.click(CENTER.itemA);
    d.down(); await tick(); d.up(); await tick();     // koniec 1. nagrania
    check('reset_first_recording', 'pierwsze nagranie ma jedno zaznaczenie',
          sentXPaths(d.log).length === 1, `xpath=${JSON.stringify(sentXPaths(d.log))}`);

    // drugie nagranie zaczyna z czystą listą
    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();
    d.click(CENTER.itemB);
    d.down(); await tick(); d.up(); await tick();

    const sent = sentXPaths(d.log);
    check('reset_second_recording', 'drugie nagranie nie dziedziczy zaznaczeń',
          sent.length === 1 && sent[0] === XPATH.itemB, `xpath=${JSON.stringify(sent)}`);
    check('reset_visit_path', 'drugie nagranie ma własną ścieżkę ekranów',
          sentVisits(d.log).length === 1, `visits=${JSON.stringify(sentVisits(d.log))}`);
}


// ------------------------------------------- ścieżka ekranów w trakcie nagrania

async function scenarioStartScreenRecorded() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.content);
    d.up(); await tick();

    const visits = sentVisits(d.log);
    check('visit_start_screen', 'nagranie zapisuje ekran, na którym się zaczęło',
          visits.length === 1 && visits[0].hash === 'hash-A' && visits[0].time === '0.0',
          `visits=${JSON.stringify(visits)}`);
    check('visit_is_first_entry', 'ekran startowy jest pierwszą pozycją, przed wskazaniami',
          sentEntries(d.log)[0].xpath === SCROLLED_XPATH,
          `entries=${JSON.stringify(sentEntries(d.log))}`);
}

async function scenarioSwipeRecordsVisit() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();       // blokada — karuzelę można przewijać

    d.advance(1000);
    d.click(CENTER.itemA);            // wskazanie na pierwszym ekranie
    d.advance(3000);
    d.swipeTo('hash-B');              // przewinięcie w trakcie mówienia
    d.advance(2000);                  // o drugim ekranie tylko mówi, nic nie klika

    d.down(); await tick(); d.up(); await tick();

    const visits = sentVisits(d.log);
    check('visit_swipe_recorded', 'przewinięcie w trakcie nagrania zapisuje ekran i moment',
          visits.length === 2 && visits[1].hash === 'hash-B' && visits[1].time === '4.0',
          `visits=${JSON.stringify(visits)}`);
    check('visit_without_click', 'ekran, na którym nic nie kliknięto, i tak trafia do ścieżki',
          sentClicks(d.log).length === 1 && visits.some((v) => v.hash === 'hash-B'),
          `clicks=${JSON.stringify(sentClicks(d.log))}`);
    check('visit_order_kept', 'ścieżka i wskazania idą w kolejności zajścia',
          sentEntries(d.log).map((e) => e.time).join(',') === '0.0,1.0,4.0',
          `entries=${JSON.stringify(sentEntries(d.log))}`);
    check('visit_fields_parallel', 'wszystkie trzy listy mają tyle samo pozycji',
          sentField(d.log, 'xpath').length === 3
              && sentField(d.log, 'xpath_hash').length === 3
              && sentField(d.log, 'xpath_time').length === 3,
          `xpath=${sentField(d.log, 'xpath').length} hash=${sentField(d.log, 'xpath_hash').length} time=${sentField(d.log, 'xpath_time').length}`);
}

async function scenarioVisitDedup() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.lock); d.up(); await tick();

    d.notifyLive('hash-A');           // karuzela zgłasza tę samą stronę co startowa
    d.advance(1000);
    d.swipeTo('hash-B');
    d.notifyLive('hash-B');           // i znowu tę samą
    d.advance(1000);
    d.swipeTo('hash-A');              // powrót na pierwszy ekran to już nowa informacja

    d.down(); await tick(); d.up(); await tick();

    const path = sentVisits(d.log).map((v) => v.hash).join(',');
    check('visit_no_duplicates', 'powtórne zgłoszenie tej samej strony nic nie dopisuje',
          path === 'hash-A,hash-B,hash-A', `path=${path}`);
}

async function scenarioVisitOnlyWhileRecording() {
    const d = await makeDom();

    d.swipeTo('hash-B');              // przewijanie przed nagraniem nie jest częścią polecenia

    d.down(); await tick();
    d.move(CENTER.content);
    d.up(); await tick();

    const visits = sentVisits(d.log);
    check('visit_ignored_outside_recording', 'przewijanie poza nagraniem nie trafia do ścieżki',
          visits.length === 1 && visits[0].hash === 'hash-B',
          `visits=${JSON.stringify(visits)}`);
}

async function scenarioDropStillSelects() {
    const d = await makeDom();

    d.down(); await tick();
    d.move(CENTER.content);                            // upuszczenie na itemB
    d.up(); await tick();

    const sent = sentXPaths(d.log);
    check('drop_selects_element', 'upuszczenie kulki nadal zaznacza element',
          sent.length === 1 && sent[0] === XPATH.itemB, `xpath=${JSON.stringify(sent)}`);
}

async function scenarioUploadUrlFromPin() {
    const withPin = await makeDom();

    withPin.down(); await tick();
    withPin.move(CENTER.content);
    withPin.up(); await tick();

    check('upload_url_from_pin', 'nagranie leci pod adres złożony z PIN-u',
          withPin.log.urls.at(-1) === UPLOAD_URL, `url=${withPin.log.urls.at(-1)}`);

    // bez PIN-u nie ma adresu — nagranie nie może pójść pod ścieżkę bez PIN-u
    const noPin = await makeDom();
    noPin.window.pinEndpoints = null;

    noPin.down(); await tick();
    noPin.move(CENTER.content);
    noPin.up(); await tick();

    check('no_pin_no_upload', 'bez PIN-u nagranie nie wychodzi',
          noPin.log.sent === 0, `sent=${noPin.log.sent}`);
}

async function scenarioClickIgnoredWhenNotLocked() {
    const d = await makeDom();

    d.down(); await tick();                            // nagrywanie z przytrzymaniem
    d.click(CENTER.itemA);                             // klik w trakcie trzymania
    d.move(CENTER.home);
    d.up(); await tick();

    check('click_ignored_unlocked', 'klik poza trybem blokady nie zaznacza',
          sentXPaths(d.log).length === 0, `xpath=${JSON.stringify(sentXPaths(d.log))}`);
}

(async () => {
    try {
        await scenarioLock();
        await scenarioDragBackHome();
        await scenarioCancelWhileLocked();
        await scenarioPlainFlow();
        await scenarioNoLeftoverHighlight();
        await scenarioStrayClick();
        await scenarioMultiSelect();
        await scenarioSelectionTimes();
        await scenarioDropHasTime();
        await scenarioMultiScreen();
        await scenarioSwipeDoesNotSelect();
        await scenarioSelectionResets();
        await scenarioDropStillSelects();
        await scenarioUploadUrlFromPin();
        await scenarioClickIgnoredWhenNotLocked();
        await scenarioStartScreenRecorded();
        await scenarioSwipeRecordsVisit();
        await scenarioVisitDedup();
        await scenarioVisitOnlyWhileRecording();
    } catch (error) {
        console.log(JSON.stringify({ error: error.message, stack: error.stack, results }));
        process.exit(2);
    }

    console.log(JSON.stringify({ results }));
    process.exit(results.every(r => r.ok) ? 0 : 1);
})();
