Files
xteink-web-emulator/tests/browser_raw_sd.py
T
evan 78f181e924 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.
2026-07-21 15:37:00 -04:00

184 lines
6.0 KiB
Python

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
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:
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"]')
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, description):
driver.execute_script(
'arguments[0].scrollIntoView({block: "center"})', button
)
before = wait_stable()['generation']
ActionChains(driver).click_and_hold(button).perform()
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, 'opening Browse Files')
press(confirm, 'opening Test directory')
press(confirm, 'highlighting example.txt')
press(confirm, 'opening example.txt')
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')
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'
)
except Exception:
state = save_artifacts()
print(json.dumps(state, indent=2))
raise
else:
save_artifacts()
finally:
driver.quit()