From 78f181e924c1d1e940c897ceade95452f51003dc Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Tue, 21 Jul 2026 15:37:00 -0400 Subject: [PATCH] feat(web): improve SD file browser Add directory navigation, folder creation, inline deletion, clear controls, and a focused editor view. Include the opt-in FAT16 vvfat experiment and fail-fast browser diagnostics; raw FAT32 remains the default. --- scripts/test-web.mjs | 4 +- tests/browser_raw_sd.py | 208 ++++++++++++++++++++++++++++--------- third_party/qemu | 2 +- web/app.js | 223 +++++++++++++++++++++++++++------------- web/index.html | 32 ++++-- web/sdstore.js | 52 +++------- web/styles.css | 38 +++++-- 7 files changed, 373 insertions(+), 186 deletions(-) diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs index 8e95297..633ba9b 100644 --- a/scripts/test-web.mjs +++ b/scripts/test-web.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { BUTTONS, DEFAULT_SD_FILES, EXAMPLE_SD_TEXT, formatBytes, setButton, mergeFirmware } from '../web/app.js'; +import { BUTTONS, formatBytes, setButton, mergeFirmware } from '../web/app.js'; import { buildFat32Image, FAT32_IMAGE_SIZE } from '../web/fat32.js'; // Merge - app image is placed at 0x10000 in a 0xFF-erased 16 MB flash with bootloader and partitions. @@ -15,8 +15,6 @@ assert.equal(flash[0x10000], 0xe9); assert.equal(flash[0x20000], 0xff); assert.throws(() => mergeFirmware(new Uint8Array([0x00]), bootloader, partitions), /0xE9/); -assert.equal(DEFAULT_SD_FILES.get('Test/example.txt'), EXAMPLE_SD_TEXT); -assert.equal(EXAMPLE_SD_TEXT.split('Lorem ipsum').length - 1, 10); assert.equal(formatBytes(512), '512 B'); assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MiB'); diff --git a/tests/browser_raw_sd.py b/tests/browser_raw_sd.py index 83f2290..894b6d2 100644 --- a/tests/browser_raw_sd.py +++ b/tests/browser_raw_sd.py @@ -1,73 +1,183 @@ +import json +import os from pathlib import Path import time + from selenium import webdriver +from selenium.common.exceptions import WebDriverException from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.firefox.options import Options -from selenium.webdriver.support.ui import WebDriverWait ROOT = Path(__file__).resolve().parent.parent +VVFAT = bool(os.environ.get('XTEINK_VVFAT')) +MODE = 'vvfat' if VVFAT else 'raw-sd' +ARTIFACT = ROOT / f'_scratch/browser-{MODE}' +BAD_MARKERS = ( + 'RuntimeError', 'Aborted()', 'index out of bounds', + 'unaligned memory access', 'SSI_PERIPHERAL:', 'Guru Meditation', + "panic'ed", 'Invalid SD card size', 'FS error', 'SPI:', 'SD:', + 'Unexpected response to cmd', 'Error writing to qcow backend', + 'write error on host side', +) options = Options() options.add_argument('-headless') +options.accept_insecure_certs = True driver = webdriver.Firefox(options=options) driver.set_page_load_timeout(60) + + +def page_state(): + try: + return driver.execute_script(''' + return { + status: document.querySelector('#status')?.textContent || '', + log: document.querySelector('#log')?.textContent || '', + generation: Number(document.querySelector('#frame-generation')?.value || -1), + runtimeErrors: window.__xteinkRuntimeErrors || [], + wasmHeap: window.__xteinkModule?.HEAPU8?.byteLength || 0, + }; + ''') + except WebDriverException as error: + return { + 'status': 'WebDriver lost the page', + 'log': '', + 'generation': -1, + 'runtimeErrors': [str(error)], + 'wasmHeap': 0, + } + + +def failure(state): + text = '\n'.join([ + state.get('status', ''), + state.get('log', ''), + *map(str, state.get('runtimeErrors', [])), + ]) + matched = [marker for marker in BAD_MARKERS if marker in text] + if matched: + return f'emulator failure detected: {matched}' + if state.get('runtimeErrors'): + return f'browser runtime error: {state["runtimeErrors"]}' + return None + + +def wait_until(predicate, description, timeout): + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + last = page_state() + problem = failure(last) + if problem: + raise RuntimeError(f'{problem} while waiting for {description}') + if predicate(last): + return last + time.sleep(0.25) + raise TimeoutError(f'timed out waiting for {description}; last state: {last}') + + +def wait_stable(quiet=5, timeout=180): + deadline = time.monotonic() + timeout + state = page_state() + last_generation = state['generation'] + stable_since = time.monotonic() + while time.monotonic() < deadline: + state = page_state() + problem = failure(state) + if problem: + raise RuntimeError(f'{problem} while waiting for display stability') + if state['generation'] != last_generation: + last_generation = state['generation'] + stable_since = time.monotonic() + elif time.monotonic() - stable_since >= quiet: + return state + time.sleep(0.25) + raise TimeoutError(f'display did not settle; last state: {state}') + + +def save_artifacts(): + state = page_state() + ARTIFACT.with_suffix('.json').write_text(json.dumps(state, indent=2)) + ARTIFACT.with_suffix('.log').write_text(state.get('log', '')) + try: + driver.save_screenshot(str(ARTIFACT.with_suffix('.png'))) + except WebDriverException: + pass + return state + + try: - driver.get('http://127.0.0.1:8180/web/?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin&variant=x3') + suffix = '&vvfat' if VVFAT else '' + url = os.environ.get( + 'XTEINK_TEST_URL', + 'http://127.0.0.1:8180/web/' + '?firmware=/_scratch/crosspoint-reader-1.4.1-release-firmware.bin' + f'&variant=x3{suffix}', + ) + driver.get(url) + confirm = driver.find_element(By.CSS_SELECTOR, '[data-button="confirm"]') - WebDriverWait(driver, 8 * 60).until(lambda _: confirm.get_attribute('disabled') is None) - - indicator = driver.find_element(By.ID, 'frame-generation') - - def generation(): - return int(indicator.get_attribute('value') or '-1') - - def wait_frame(previous, timeout=180): - WebDriverWait(driver, timeout).until(lambda _: generation() > previous) - - def wait_stable(quiet=5, timeout=180): - deadline = time.time() + timeout - last = generation() - stable_since = time.time() - while time.time() < deadline: - time.sleep(0.25) - current = generation() - if current != last: - last = current - stable_since = time.time() - elif time.time() - stable_since >= quiet: - return current - raise RuntimeError('display did not settle') - + if os.environ.get('XTEINK_INJECT_FAILURE'): + driver.execute_script("window.dispatchEvent(new ErrorEvent('error', {message: 'RuntimeError: injected test failure'}))") + wait_until( + lambda _: confirm.get_dom_attribute('disabled') is None, + 'automatic cold boot and enabled controls', + 240, + ) wait_stable() - def press(button): - driver.execute_script('arguments[0].scrollIntoView({block: "center"})', button) - before = wait_stable() + def press(button, description): + driver.execute_script( + 'arguments[0].scrollIntoView({block: "center"})', button + ) + before = wait_stable()['generation'] ActionChains(driver).click_and_hold(button).perform() - time.sleep(0.3) - ActionChains(driver).release(button).perform() - wait_frame(before) - wait_stable() + try: + time.sleep(0.3) + finally: + ActionChains(driver).release(button).perform() + wait_until( + lambda state: state['generation'] > before, + f'frame after {description}', + 180, + ) + return wait_stable() - press(confirm) # Browse Files - press(confirm) # Test directory - press(confirm) # highlight example.txt (browser indexes a preview) - press(confirm) # Open example.txt into the reader - wait_stable(quiet=10, timeout=600) + press(confirm, 'opening Browse Files') + press(confirm, 'opening Test directory') + press(confirm, 'highlighting example.txt') + press(confirm, 'opening example.txt') - status = driver.find_element(By.ID, 'status').text - log = driver.find_element(By.ID, 'log').text - (ROOT / '_scratch/browser-raw-sd.log').write_text(log) - driver.save_screenshot(str(ROOT / '_scratch/browser-raw-sd.png')) + down = driver.find_element(By.CSS_SELECTOR, '[data-button="down"]') + up = driver.find_element(By.CSS_SELECTOR, '[data-button="up"]') + press(down, 'turning to reader page 2') + press(down, 'turning to reader page 3') + press(up, 'turning back to reader page 2') - bad = ( - 'RuntimeError', 'Guru Meditation', "panic'ed", 'Invalid SD card size', - 'FS error', 'SPI:', 'SD:', 'Unexpected response to cmd', + back = driver.find_element(By.CSS_SELECTOR, '[data-button="back"]') + press(back, 'closing the reader') + press(confirm, 'reopening example.txt') + final_state = wait_stable(quiet=10, timeout=600) + + problem = failure(final_state) + if problem: + raise RuntimeError(problem) + if final_state['generation'] < 10: + raise RuntimeError( + f'reader workflow produced too few frames: {final_state["generation"]}' + ) + + print( + f'browser {MODE} test passed at frame generation ' + f'{final_state["generation"]}, ' + f'wasm heap {final_state["wasmHeap"] / 1024 / 1024:.1f} MiB' ) - matched = [marker for marker in bad if marker in log or marker in status] - if matched: - raise RuntimeError(f'emulator failure detected: {matched}') - print(f'browser raw-SD test passed at frame generation {generation()}') +except Exception: + state = save_artifacts() + print(json.dumps(state, indent=2)) + raise +else: + save_artifacts() finally: driver.quit() diff --git a/third_party/qemu b/third_party/qemu index cbeba3f..962fc6e 160000 --- a/third_party/qemu +++ b/third_party/qemu @@ -1 +1 @@ -Subproject commit cbeba3fbe9acc47e4c62334b698998af0c7f3344 +Subproject commit 962fc6e71d8e33706a5e29c97f75bc5065362ebf diff --git a/web/app.js b/web/app.js index d640a38..441b382 100644 --- a/web/app.js +++ b/web/app.js @@ -1,6 +1,5 @@ import { - listSdFiles, readSdFile, writeSdFile, deleteSdFile, readAllSdFiles, - seedDefaultsIfEmpty, saveBootState, loadBootState, + listSdFiles, readSdFile, writeSdFile, mkdirSd, deleteSdFile, clearSdFiles, readAllSdFiles, } from './sdstore.js'; import { buildFat32Image } from './fat32.js'; @@ -44,10 +43,6 @@ async function fetchBin(url) { return new Uint8Array(await response.arrayBuffer()); } -const LOREM_IPSUM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; -export const EXAMPLE_SD_TEXT = Array(10).fill(LOREM_IPSUM).join('\n\n'); -export const DEFAULT_SD_FILES = new Map([['Test/example.txt', EXAMPLE_SD_TEXT]]); - // A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table. async function prepareFirmware(bytes, assetRoot) { if (bytes.length === FLASH_SIZE) { @@ -105,6 +100,7 @@ function drawFrame(module, canvas) { function startDisplayLoop(module, canvas, setStatus, onReady) { let generation = -1; let powerHeld = false; + let heldFrames = 0; let ready = false; const update = () => { const nextGeneration = module._xteink_wasm_frame_generation(); @@ -117,7 +113,7 @@ function startDisplayLoop(module, canvas, setStatus, onReady) { powerHeld = true; setButton(module, 'power', true); setStatus('Holding power for cold boot…'); - } else if (!ready) { + } else if (!ready && ++heldFrames >= 2) { setButton(module, 'power', false); ready = true; setStatus('Running.'); @@ -178,6 +174,9 @@ const logBuffer = []; const LOG_MAX_LINES = 1000; const DEBUG_LOG_KEY = 'xteink-debug-log'; let activeModule = null; +if (typeof window !== 'undefined') { + window.__xteinkRuntimeErrors = []; +} export function formatBytes(bytes) { if (bytes < 1024) { @@ -195,17 +194,27 @@ export function formatBytes(bytes) { const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']); let selectedSdPath = null; +let currentSdDirectory = ''; function isTextPath(path) { return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase()); } +function showEditor(show) { + document.querySelector('#files-browse').hidden = show; + document.querySelector('#files-edit').hidden = !show; +} + +function closeEditor() { + selectedSdPath = null; + showEditor(false); +} + async function selectSdFile(path) { selectedSdPath = path; const editor = document.querySelector('#file-editor'); const save = document.querySelector('#file-save'); - const del = document.querySelector('#file-delete'); - del.hidden = false; + document.querySelector('#file-edit-name').textContent = path.split('/').pop(); try { const bytes = await readSdFile(path); if (isTextPath(path)) { @@ -222,49 +231,82 @@ async function selectSdFile(path) { editor.readOnly = true; save.hidden = true; } - for (const button of document.querySelectorAll('#file-list button')) { - button.classList.toggle('selected', button.dataset.path === path); - } + showEditor(true); } async function renderFiles() { const list = document.querySelector('#file-list'); - const editor = document.querySelector('#file-editor'); const entries = await listSdFiles(); - list.replaceChildren(); - let firstPath = null; - - for (const entry of entries) { - if (entry.directory) { - const directory = document.createElement('div'); - directory.className = 'file-directory'; - directory.textContent = entry.path; - list.append(directory); - continue; - } - const button = document.createElement('button'); - button.type = 'button'; - button.dataset.path = entry.path; - const path = document.createElement('span'); - path.textContent = entry.path; - const size = document.createElement('small'); - size.textContent = formatBytes(entry.size); - button.append(path, size); - button.addEventListener('click', () => selectSdFile(entry.path)); - list.append(button); - firstPath ||= entry.path; + if (currentSdDirectory && !entries.some(entry => entry.directory && entry.path === `${currentSdDirectory}/`)) { + currentSdDirectory = ''; } - const stillPresent = entries.some(entry => entry.path === selectedSdPath); - const target = stillPresent ? selectedSdPath : firstPath; - if (target) { - await selectSdFile(target); - } else { - selectedSdPath = null; - editor.value = 'The SD card is empty.'; - editor.readOnly = true; - document.querySelector('#file-save').hidden = true; - document.querySelector('#file-delete').hidden = true; + const prefix = currentSdDirectory ? `${currentSdDirectory}/` : ''; + const visibleEntries = entries.filter(entry => { + if (!entry.path.startsWith(prefix)) { + return false; + } + const relativePath = entry.path.slice(prefix.length).replace(/\/$/, ''); + return relativePath && !relativePath.includes('/'); + }); + + document.querySelector('#file-path').textContent = `/sdcard${currentSdDirectory ? `/${currentSdDirectory}` : ''}`; + document.querySelector('#file-back').disabled = !currentSdDirectory; + + const body = list.querySelector('tbody'); + body.replaceChildren(); + + if (!visibleEntries.length) { + const row = body.insertRow(); + const cell = row.insertCell(); + cell.colSpan = 4; + cell.className = 'file-empty'; + cell.textContent = 'This folder is empty'; + return; + } + + const childCount = dirPrefix => entries.filter(e => { + const rest = e.path.slice(dirPrefix.length).replace(/\/$/, ''); + return e.path.startsWith(dirPrefix) && rest && !rest.includes('/'); + }).length; + + for (const entry of visibleEntries) { + const name = entry.path.slice(prefix.length).replace(/\/$/, ''); + const row = body.insertRow(); + row.className = entry.directory ? 'directory' : 'file'; + row.insertCell().textContent = entry.directory ? '\u{1F4C1}' : '\u{1F4C4}'; + row.insertCell().textContent = name; + if (entry.directory) { + const n = childCount(entry.path); + row.insertCell().textContent = `${n} item${n === 1 ? '' : 's'}`; + } else { + row.insertCell().textContent = formatBytes(entry.size); + } + + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'file-del'; + del.textContent = '\u2715'; + del.title = `Delete ${name}`; + del.addEventListener('click', async event => { + event.stopPropagation(); + if (!confirm(`Delete ${name}?`)) { + return; + } + await deleteSdFile(entry.path); + await renderFiles(); + }); + row.insertCell().append(del); + + row.addEventListener('click', () => { + if (entry.directory) { + currentSdDirectory = entry.path.replace(/\/$/, ''); + selectedSdPath = null; + renderFiles(); + } else { + selectSdFile(entry.path); + } + }); } } @@ -313,26 +355,54 @@ function initializeInspector() { }); } wireFileControls(); - seedDefaultsIfEmpty(DEFAULT_SD_FILES).then(renderFiles); + renderFiles(); renderDebug(); setInterval(renderDebug, 1000); } function wireFileControls() { + const inDir = name => (currentSdDirectory ? `${currentSdDirectory}/${name}` : name); document.querySelector('#file-add').addEventListener('change', async event => { for (const file of event.target.files) { - await writeSdFile(file.name, new Uint8Array(await file.arrayBuffer())); + await writeSdFile(inDir(file.name), new Uint8Array(await file.arrayBuffer())); } event.target.value = ''; await renderFiles(); }); document.querySelector('#file-new').addEventListener('click', async () => { - const name = prompt('New file path (e.g. Notes/todo.txt):'); + const name = prompt('New file name:')?.replace(/^\/+|\/+$/g, ''); if (!name) { return; } - await writeSdFile(name, ''); - selectedSdPath = name; + const path = inDir(name); + await writeSdFile(path, ''); + await renderFiles(); + await selectSdFile(path); + }); + document.querySelector('#file-new-folder').addEventListener('click', async () => { + const name = prompt('New folder name:')?.replace(/^\/+|\/+$/g, ''); + if (!name) { + return; + } + await mkdirSd(inDir(name)); + await renderFiles(); + }); + document.querySelector('#file-close').addEventListener('click', () => { + closeEditor(); + renderFiles(); + }); + document.querySelector('#file-back').addEventListener('click', async () => { + currentSdDirectory = currentSdDirectory.split('/').slice(0, -1).join('/'); + selectedSdPath = null; + await renderFiles(); + }); + document.querySelector('#file-reset').addEventListener('click', async () => { + if (!confirm('Delete all files from the SD card?')) { + return; + } + await clearSdFiles(); + currentSdDirectory = ''; + selectedSdPath = null; await renderFiles(); }); document.querySelector('#file-save').addEventListener('click', async () => { @@ -340,14 +410,13 @@ function wireFileControls() { return; } await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value); - await renderFiles(); }); document.querySelector('#file-delete').addEventListener('click', async () => { if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) { return; } await deleteSdFile(selectedSdPath); - selectedSdPath = null; + closeEditor(); await renderFiles(); }); document.querySelector('#file-reboot').addEventListener('click', () => location.reload()); @@ -382,11 +451,13 @@ async function boot(file, variant, setStatus) { prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url), fetchBin(new URL('esp32c3-rom.bin', artifactRoot)), ]); - await seedDefaultsIfEmpty(DEFAULT_SD_FILES); const sdFiles = await readAllSdFiles(); - setStatus(`Building SD card image from ${sdFiles.length} browser file(s)…`); - const sdImage = buildFat32Image(sdFiles); - const sdMessage = `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`; + const useVvfat = new URL(location.href).searchParams.has('vvfat'); + setStatus(`${useVvfat ? 'Staging' : 'Building SD card image from'} ${sdFiles.length} browser file(s)…`); + const sdImage = useVvfat ? null : buildFat32Image(sdFiles); + const sdMessage = useVvfat + ? `Using experimental vvfat backed by ${sdFiles.length} browser file(s); guest writes are discarded on reboot.` + : `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`; console.log(sdMessage); logLine(sdMessage); const { default: createQemu } = await import(qemuUrl); @@ -405,7 +476,9 @@ async function boot(file, variant, setStatus) { '-nic', 'none', '-d', qemuLogFlags, '-drive', 'file=/flash.bin,if=mtd,format=raw', - '-drive', 'file=/sd.img,if=sd,format=raw', + '-drive', useVvfat + ? 'file=fat:16:rw:/sdcard,if=sd,format=raw' + : 'file=/sd.img,if=sd,format=raw', ], locateFile: path => new URL(path, artifactRoot).href, mainScriptUrlOrBlob: qemuUrl.href, @@ -418,7 +491,16 @@ async function boot(file, variant, setStatus) { options.FS.mkdirTree('/var/tmp'); options.FS.writeFile('/bios/esp32c3-rom.bin', rom); options.FS.writeFile('/flash.bin', firmware); - options.FS.writeFile('/sd.img', sdImage); + if (useVvfat) { + for (const { path, bytes } of sdFiles) { + const slash = path.lastIndexOf('/'); + const directory = slash < 0 ? '/sdcard' : `/sdcard/${path.slice(0, slash)}`; + options.FS.mkdirTree(directory); + options.FS.writeFile(`/sdcard/${path}`, bytes); + } + } else { + options.FS.writeFile('/sd.img', sdImage); + } }]; setStatus('Starting emulator…'); @@ -450,6 +532,14 @@ function initialize() { status.textContent = message; status.classList.toggle('error', error); }; + const recordRuntimeError = value => { + const message = value instanceof Error ? value.stack || value.message : String(value); + window.__xteinkRuntimeErrors.push(message); + logLine(`[runtime] ${message}`); + setStatus(`Runtime error: ${message}`, true); + }; + window.addEventListener('error', event => recordRuntimeError(event.message || event.error || 'Unknown worker error')); + window.addEventListener('unhandledrejection', event => recordRuntimeError(event.reason || 'Unhandled rejection')); const start = async (file, variant = variantInput.value) => { bootButton.disabled = true; @@ -480,7 +570,6 @@ function initialize() { setStatus('Choose a firmware image first.', true); return; } - await saveBootState(file, variantInput.value); start(file); }); @@ -493,21 +582,7 @@ function initialize() { } return response.blob(); }) - .then(async blob => { - const variant = params.get('variant') || 'x3'; - const firmware = new File([blob], 'firmware.bin'); - await saveBootState(firmware, variant); - await start(firmware, variant); - }) - .catch(error => setStatus(error.message || String(error), true)); - } else { - loadBootState() - .then(saved => { - if (saved) { - variantInput.value = saved.variant; - start(saved.firmware, saved.variant); - } - }) + .then(blob => start(new File([blob], 'firmware.bin'), params.get('variant') || 'x3')) .catch(error => setStatus(error.message || String(error), true)); } } diff --git a/web/index.html b/web/index.html index ace17f7..86dfaa8 100644 --- a/web/index.html +++ b/web/index.html @@ -66,16 +66,30 @@