mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-09-01 16:48:23 +00:00
test: exercise map installation through Chromium filesystem APIs
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
B8 smoke harness: exercises the real browser File System Access API
|
||||
(Origin Private File System) instead of the in-memory mock. Served from
|
||||
tests/web/ over a localhost secure context by
|
||||
run_map_installer_browser_smoke.py, which also serves smoke-metadata.json
|
||||
in memory. The phase and run id travel in the query string; the driver
|
||||
then collects window.__smokeResult.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pyxis map installer browser smoke</title>
|
||||
<style>body { font-family: system-ui, sans-serif; padding: 2rem; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Map installer browser smoke</h1>
|
||||
<pre id="status">pending</pre>
|
||||
<script type="module">
|
||||
import {
|
||||
decodeActiveSelection,
|
||||
installMuiZip,
|
||||
parseSparseManifest,
|
||||
validatePng,
|
||||
} from '../../docs/flasher/js/map-installer.js';
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
function setStage(name) { statusEl.textContent = `stage: ${name}`; }
|
||||
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAA1UlEQVR4nO3BMQEAAADCoPVP7WULoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAGwEtAAHMpTgHAAAAAElFTkSuQmCC';
|
||||
const PNG = Uint8Array.from(atob(PNG_BASE64), ch => ch.charCodeAt(0));
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
// Minimal stored (uncompressed) ZIP for one tile entry, mirroring the
|
||||
// node test's storedZip but with the metadata a real browser zip tool
|
||||
// produces: UTF-8 flag bit and a regular-file external attribute.
|
||||
function storedZip(name, payload) {
|
||||
const filename = new TextEncoder().encode(name);
|
||||
const data = new Uint8Array(payload);
|
||||
const checksum = crc32(data);
|
||||
const localHeader = new Uint8Array(30);
|
||||
const lv = new DataView(localHeader.buffer);
|
||||
lv.setUint32(0, 0x04034b50, true);
|
||||
lv.setUint16(4, 20, true);
|
||||
lv.setUint16(6, 0x0800, true); // UTF-8 file name flag
|
||||
lv.setUint32(14, checksum, true);
|
||||
lv.setUint32(18, data.length, true);
|
||||
lv.setUint32(22, data.length, true);
|
||||
lv.setUint16(26, filename.length, true);
|
||||
const centralHeader = new Uint8Array(46);
|
||||
const cv = new DataView(centralHeader.buffer);
|
||||
cv.setUint32(0, 0x02014b50, true);
|
||||
cv.setUint16(4, 20, true);
|
||||
cv.setUint16(6, 20, true);
|
||||
cv.setUint16(8, 0x0800, true); // UTF-8 file name flag
|
||||
cv.setUint32(16, checksum, true);
|
||||
cv.setUint32(20, data.length, true);
|
||||
cv.setUint32(24, data.length, true);
|
||||
cv.setUint16(28, filename.length, true);
|
||||
cv.setUint32(38, 0x81a40000, true); // unix mode 0644 regular file
|
||||
cv.setUint32(42, 0, true);
|
||||
const end = new Uint8Array(22);
|
||||
const ev = new DataView(end.buffer);
|
||||
ev.setUint32(0, 0x06054b50, true);
|
||||
ev.setUint16(8, 1, true);
|
||||
ev.setUint16(10, 1, true);
|
||||
ev.setUint32(12, centralHeader.length + filename.length, true);
|
||||
ev.setUint32(16, 30 + filename.length + data.length, true);
|
||||
return new Blob([localHeader, filename, data, centralHeader, filename, end],
|
||||
{type: 'application/zip'});
|
||||
}
|
||||
|
||||
async function readAll(fileHandle) {
|
||||
const file = await fileHandle.getFile();
|
||||
return new Uint8Array(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
async function readIfExists(directory, name) {
|
||||
try {
|
||||
return await readAll(await directory.getFileHandle(name));
|
||||
} catch (error) {
|
||||
if (error && error.name === 'NotFoundError') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyComposition(pyxis, metadata, expectedGeneration) {
|
||||
const pack = await (await pyxis.getDirectoryHandle('packs')).getDirectoryHandle(metadata.packId);
|
||||
const manifest = await readAll(await pack.getFileHandle('manifest.pmp'));
|
||||
const parsed = parseSparseManifest(manifest);
|
||||
// parseSparseManifest omits version: v3 is indexless (empty rowSpans).
|
||||
if (parsed.rowSpans.length !== 0 || parsed.tileCount === undefined) {
|
||||
return {ok: false, reason: `manifest not v3: ${JSON.stringify(parsed)}`};
|
||||
}
|
||||
if (parsed.packId !== metadata.packId) return {ok: false, reason: 'manifest packId mismatch'};
|
||||
const tile = await readAll(await (await (await (await pack.getDirectoryHandle('tiles'))
|
||||
.getDirectoryHandle('2')).getDirectoryHandle('1')).getFileHandle('1.png'));
|
||||
await validatePng(tile, 'smoke tile');
|
||||
if (tile.length !== PNG.length) return {ok: false, reason: 'tile size mismatch'};
|
||||
for (let i = 0; i < PNG.length; i++) {
|
||||
if (tile[i] !== PNG[i]) return {ok: false, reason: `tile byte ${i} differs`};
|
||||
}
|
||||
// v3 PMAS pack records carry only packId (indexless; spans live in the
|
||||
// manifest and are not repeated in the activation record).
|
||||
const expectedPacks = {packId: metadata.packId};
|
||||
const styleRecord = decodeActiveSelection(
|
||||
await readAll(await (await pyxis.getDirectoryHandle('map-sets'))
|
||||
.getFileHandle(`${metadata.mapSetId}.pmas`)));
|
||||
const slotRecord = decodeActiveSelection(
|
||||
await readAll(await pyxis.getFileHandle('active-pack.0')));
|
||||
for (const [label, record] of [['style', styleRecord], ['slot', slotRecord]]) {
|
||||
if (record.version !== 3) return {ok: false, reason: `${label} version ${record.version}`};
|
||||
if (record.generation !== expectedGeneration) return {ok: false, reason: `${label} generation ${record.generation}`};
|
||||
if (record.mapSetId !== metadata.mapSetId) return {ok: false, reason: `${label} mapSetId ${record.mapSetId}`};
|
||||
if (record.attribution !== metadata.attribution) return {ok: false, reason: `${label} attribution`};
|
||||
if (JSON.stringify(record.packs) !== JSON.stringify([expectedPacks])) {
|
||||
return {ok: false, reason: `${label} packs ${JSON.stringify(record.packs)}`};
|
||||
}
|
||||
}
|
||||
return {ok: true};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// The Python driver serves this page over localhost and writes the run
|
||||
// metadata to smoke-metadata.json before launch; the phase and run id
|
||||
// travel in the query string (with a nonce defeating the bfcache) so
|
||||
// each phase is an independent real load.
|
||||
const params = new URLSearchParams(location.search);
|
||||
const runId = params.get('run') || window.__smokeRunId;
|
||||
const metadata = await (await fetch('./smoke-metadata.json')).json();
|
||||
const isReload = params.get('phase') === 'reload';
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const smoke = await root.getDirectoryHandle(`pyxis-map-smoke-${runId}`, {create: true});
|
||||
try {
|
||||
const result = {
|
||||
install: false,
|
||||
readback: false,
|
||||
reload: false,
|
||||
exclusive_option_ignored: false,
|
||||
};
|
||||
if (!isReload) {
|
||||
// Reload-persistence token: written through a real handle now and
|
||||
// re-read after a full page reload in the second phase.
|
||||
const token = await smoke.getFileHandle('smoke-token', {create: true});
|
||||
const writable = await token.createWritable();
|
||||
await writable.write(`smoke:${runId}`);
|
||||
await writable.close();
|
||||
// The File System Access API has no exclusive create option: the
|
||||
// second call must return the existing handle, not throw.
|
||||
const probe = await smoke.getFileHandle('smoke-probe', {create: true, exclusive: true});
|
||||
const probeWritable = await probe.createWritable();
|
||||
await probeWritable.write('first');
|
||||
await probeWritable.close();
|
||||
const probeAgain = await smoke.getFileHandle('smoke-probe', {create: true, exclusive: true});
|
||||
const probeFile = await probeAgain.getFile();
|
||||
const probeText = new TextDecoder().decode(await probeFile.arrayBuffer());
|
||||
if (probeText !== 'first') throw new Error(`exclusive probe changed content: ${probeText}`);
|
||||
result.exclusive_option_ignored = true;
|
||||
setStage('install');
|
||||
await installMuiZip({
|
||||
archive: storedZip('2/1/1.png', PNG),
|
||||
rootDirectory: smoke,
|
||||
metadata,
|
||||
});
|
||||
const composition = await verifyComposition(
|
||||
await smoke.getDirectoryHandle('pyxis-map'), metadata, 1);
|
||||
if (composition.ok) { result.install = true; result.readback = true; }
|
||||
window.__smokeResult = {...result, composition};
|
||||
statusEl.textContent = `install done: ${JSON.stringify(window.__smokeResult)}`;
|
||||
return;
|
||||
}
|
||||
// Reload phase: OPFS must have survived a full page reload.
|
||||
setStage('reload');
|
||||
const token = await readIfExists(smoke, 'smoke-token');
|
||||
const tokenOk = token !== null && new TextDecoder().decode(token) === `smoke:${runId}`;
|
||||
const composition = await verifyComposition(
|
||||
await smoke.getDirectoryHandle('pyxis-map'), metadata, 1);
|
||||
window.__smokeResult = {
|
||||
install: true,
|
||||
readback: true,
|
||||
reload: tokenOk && composition.ok,
|
||||
exclusive_option_ignored: true,
|
||||
tokenOk,
|
||||
composition,
|
||||
};
|
||||
statusEl.textContent = `reload done: ${JSON.stringify(window.__smokeResult)}`;
|
||||
} finally {
|
||||
// Clean only this run's OPFS test directory, and only after the
|
||||
// reload phase has captured its results.
|
||||
if (isReload) {
|
||||
await root.removeEntry(`pyxis-map-smoke-${runId}`, {recursive: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
window.__smokeResult = {
|
||||
error: `${error && error.name || 'Error'}: ${error && error.message || error}`,
|
||||
stack: (error && error.stack) || '',
|
||||
stage: window.__smokeStage || 'unknown',
|
||||
};
|
||||
statusEl.textContent = `failed: ${JSON.stringify(window.__smokeResult)}`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,200 @@
|
||||
"""B8: real Chromium OPFS smoke for the browser map installer.
|
||||
|
||||
Runs the public ``installMuiZip`` path against a synthetic one-tile ZIP on
|
||||
a localhost secure context, reads the manifest/style/slot back through real
|
||||
File System Access handles, reloads the page to verify OPFS persistence,
|
||||
and guards against reintroducing the false ``exclusive``-create assumption.
|
||||
|
||||
This is a required pre-publication command, not a CI job: the repository's
|
||||
CI does not provision a Chromium browser. Locates a Playwright-managed
|
||||
Chromium build and drives it over the DevTools protocol.
|
||||
|
||||
Usage:
|
||||
python3 tests/web/run_map_installer_browser_smoke.py
|
||||
|
||||
Exit code 0 only when exactly one valid result record is emitted.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPO_ROOT = HERE.parents[1]
|
||||
HARNESS = "/tests/web/map_installer_browser_harness.html"
|
||||
METADATA = {
|
||||
"packId": "smoke-pack",
|
||||
"mapSetId": "osm-bright",
|
||||
"name": "Smoke Pack",
|
||||
"attribution": "(c) OpenMapTiles (c) OpenStreetMap contributors",
|
||||
"source": "Oxed's Map Tile Downloader (OSM Bright)",
|
||||
"license": "OSM ODbL; style CC-BY-4.0/BSD-3-Clause",
|
||||
}
|
||||
PORT = None
|
||||
|
||||
|
||||
def find_chromium() -> str:
|
||||
for env_name in ("PYXIS_SMOKE_CHROMIUM", "CHROME_PATH"):
|
||||
candidate = os.environ.get(env_name)
|
||||
if candidate and Path(candidate).is_file():
|
||||
return candidate
|
||||
cache = Path.home() / ".cache" / "ms-playwright"
|
||||
if cache.is_dir():
|
||||
for build in sorted(cache.glob("chromium-*"), reverse=True):
|
||||
for pattern in ("chrome-linux64/chrome", "chrome-linux/chrome"):
|
||||
candidate = build / pattern
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
for name in ("chromium", "chromium-browser", "google-chrome",
|
||||
"google-chrome-stable"):
|
||||
candidate = shutil.which(name)
|
||||
if candidate:
|
||||
return candidate
|
||||
raise SystemExit(
|
||||
"No Chromium found. Install one (e.g. `python3 -m playwright install "
|
||||
"chromium`) or set PYXIS_SMOKE_CHROMIUM to a chrome binary."
|
||||
)
|
||||
|
||||
|
||||
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def make_handler(metadata_bytes):
|
||||
"""Serve the repo tree, but answer the harness's metadata fetch from an
|
||||
in-memory payload so the smoke never writes a scratch file into the
|
||||
repository working tree."""
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(REPO_ROOT), **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if path.endswith("/smoke-metadata.json"):
|
||||
body = metadata_bytes
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Keep the smoke output quiet; only the final JSON is emitted.
|
||||
pass
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def run_phase(page, run_id, phase, timeout_s=90):
|
||||
"""Run one harness phase and return window.__smokeResult.
|
||||
|
||||
The phase and run id travel in the query string (with a nonce defeating
|
||||
the bfcache) so each phase is a real, independent load; the driver's
|
||||
in-memory metadata endpoint feeds the harness.
|
||||
"""
|
||||
page.goto(
|
||||
f"http://127.0.0.1:{PORT}{HARNESS}"
|
||||
f"?phase={phase}&run={run_id}&nonce={phase}"
|
||||
)
|
||||
deadline = time.time() + timeout_s
|
||||
status = ""
|
||||
while time.time() < deadline:
|
||||
result = page.evaluate("window.__smokeResult")
|
||||
if result is not None:
|
||||
return result
|
||||
status = page.evaluate(
|
||||
"document.getElementById('status') ? "
|
||||
"document.getElementById('status').textContent : ''"
|
||||
)
|
||||
page.wait_for_timeout(100)
|
||||
raise SystemExit(
|
||||
f"phase {phase}: harness did not report in {timeout_s}s; status={status!r}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"The playwright Python package is required for this smoke. "
|
||||
"Install it in a scratch venv (browser binaries are picked up "
|
||||
"from ~/.cache/ms-playwright or PYXIS_SMOKE_CHROMIUM)."
|
||||
)
|
||||
|
||||
global PORT
|
||||
PORT = free_port()
|
||||
metadata_bytes = json.dumps(METADATA).encode("utf-8")
|
||||
server = ThreadingHTTPServer(("127.0.0.1", PORT), make_handler(metadata_bytes))
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
chromium = find_chromium()
|
||||
run_id = str(int(time.time() * 1000))
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
executable_path=chromium,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
],
|
||||
)
|
||||
try:
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
result_install = run_phase(page, run_id, "install")
|
||||
if "error" in result_install:
|
||||
raise SystemExit(
|
||||
"install phase failed: "
|
||||
f"{result_install['error']}\n"
|
||||
f"{result_install.get('stack', '')}\n"
|
||||
f"debug={result_install.get('debug', '')}"
|
||||
)
|
||||
if not result_install.get("install") or \
|
||||
not result_install.get("readback"):
|
||||
raise SystemExit(f"install phase incomplete: {result_install}")
|
||||
if not result_install.get("exclusive_option_ignored"):
|
||||
raise SystemExit(f"exclusive guard failed: {result_install}")
|
||||
result_reload = run_phase(page, run_id, "reload")
|
||||
if "error" in result_reload:
|
||||
raise SystemExit(f"reload phase failed: {result_reload}")
|
||||
finally:
|
||||
browser.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
expected = {
|
||||
"install": True,
|
||||
"readback": True,
|
||||
"reload": True,
|
||||
"exclusive_option_ignored": True,
|
||||
}
|
||||
record = {key: bool(result_reload.get(key)) for key in expected}
|
||||
if record != expected or result_reload.get("tokenOk") is not True \
|
||||
or not result_reload.get("composition", {}).get("ok"):
|
||||
json.dump({"ok": False, "result": result_reload}, sys.stdout)
|
||||
sys.stdout.write("\n")
|
||||
raise SystemExit(1)
|
||||
json.dump({"ok": True, "result": record}, sys.stdout)
|
||||
sys.stdout.write("\n")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user