summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 02:59:53 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 02:59:53 +0800
commit822c092790bad4b1c0d780c1fafee063d70c91f6 (patch)
treec4a7d9f740cc1c8bba39c4ded0fc3da15fcc5a5f /tools
parent183c4869e6baf03ad12d10ab8deeaec656a7d348 (diff)
downloadsomhairles-dream-fsharp-822c092790bad4b1c0d780c1fafee063d70c91f6.tar.gz
fix(viewer): guard step loads by generation and run identity, add stale-load regression
Diffstat (limited to 'tools')
-rwxr-xr-xtools/acceptance/e2e_acceptance.py195
1 files changed, 192 insertions, 3 deletions
diff --git a/tools/acceptance/e2e_acceptance.py b/tools/acceptance/e2e_acceptance.py
index f792923..ee75ee6 100755
--- a/tools/acceptance/e2e_acceptance.py
+++ b/tools/acceptance/e2e_acceptance.py
@@ -17,7 +17,7 @@ Usage:
Preconditions:
dotnet tool restore (if tools are pinned in .config/dotnet-tools.json)
- dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj --outDir public
+ dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj --outDir public --noRestore --noCache
dotnet build SomhairlesDream.sln -c Release
real bpy interpreter available (default /tmp/bpyenv/bin/python)
"""
@@ -87,6 +87,32 @@ GEOMETRY_JS = """
"""
+SCENE_JS = """
+() => {
+ const v = window.SOMHAIRLES_VIEWER;
+ if (!v || !v.group) return { ready: false, reason: 'viewer handle missing' };
+ const kids = Array.from(v.group.children).map((c) => ({
+ type: c.type,
+ isMesh: c.isMesh === true,
+ color: c.material && c.material.color && c.material.color.getHexString
+ ? c.material.color.getHexString() : null,
+ childCount: c.children ? c.children.length : 0
+ }));
+ const box = new THREE.Box3().setFromObject(v.group);
+ const size = new THREE.Vector3();
+ if (!box.isEmpty()) box.getSize(size);
+ return {
+ ready: true,
+ childCount: v.group.children.length,
+ kids: kids,
+ boxSize: box.isEmpty() ? null : [size.x, size.y, size.z],
+ note: (document.querySelector('#viewport-note') || {}).textContent || '',
+ meshLabel: (document.querySelector('#mesh-label') || {}).textContent || ''
+ };
+}
+"""
+
+
def log(msg):
print(f"[acceptance] {msg}", flush=True)
@@ -222,6 +248,164 @@ def run_flow(page, base_url, run_id, viewport_label, results, evidence, shot_pre
results.append((bool(href), f"{viewport_label}: manifest link exposed ({href})"))
+def stale_load_regression(browser, base_url, track, results, evidence):
+ """Regression for async step-load ownership across runs.
+
+ Intercepts steps GLB requests and fulfills them late, out of order, or
+ with errors to prove stale responses from superseded runs never repaint
+ geometry or notes, while the current owner's loads keep working.
+ """
+ page = browser.new_page(viewport={"width": 1280, "height": 900})
+
+ def ignore_intentional_500(m):
+ return "status of 500" in m.text \
+ and "/api/artifacts/steps" in (m.location or {}).get("url", "")
+
+ track(page, ignore=ignore_intentional_500)
+ stamp = int(time.time() * 1000)
+ held = []
+
+ def hold_route(route):
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query)
+ held.append({
+ "route": route,
+ "project": (q.get("projectId") or [""])[0],
+ "run": (q.get("runId") or [""])[0],
+ "path": (q.get("path") or [""])[0],
+ "done": False,
+ })
+
+ page.route("**/api/artifacts/steps*", hold_route)
+ page.goto(f"{base_url}/?freeze=1", wait_until="domcontentloaded")
+ wait_text(page, "#status-label", "待机")
+
+ def start_run(run_id):
+ page.fill("#run-id-input", run_id)
+ render_box = page.query_selector("#run-render-input")
+ if render_box and render_box.is_checked():
+ render_box.set_checked(False)
+ page.click("#run-start-button")
+ wait_text(page, "#stream-state", "已连接")
+
+ def held_for(run_id, path, timeout_s=120):
+ deadline = time.time() + timeout_s
+ while time.time() < deadline:
+ for entry in held:
+ if not entry["done"] and entry["run"] == run_id and entry["path"] == path:
+ return entry
+ page.wait_for_timeout(300)
+ raise AssertionError(
+ f"no held steps request for run={run_id} path={path}; "
+ f"held={[(e['run'], e['path'], e['done']) for e in held]}")
+
+ def release(entry, status=200):
+ if status == 200:
+ q = urllib.parse.urlencode({
+ "projectId": entry["project"], "runId": entry["run"], "path": entry["path"],
+ })
+ resp_status, ctype, body = fetch_bytes(f"{base_url}/api/artifacts/steps?{q}")
+ if resp_status != 200:
+ raise AssertionError(f"artifact fetch for fulfill failed ({resp_status})")
+ else:
+ ctype, body = "text/plain", b"boom"
+ entry["route"].fulfill(status=status, content_type=ctype, body=body)
+ entry["done"] = True
+
+ def is_frame(g):
+ return bool(g.get("kids")) and g["kids"][0]["type"] == "Mesh" \
+ and g["kids"][0]["color"] == "d7a948"
+
+ def is_glb(g):
+ return bool(g.get("kids")) and g["kids"][0]["type"] == "Group"
+
+ def box_ok(g):
+ return g.get("boxSize") and all(
+ isinstance(s, (int, float)) and s > 0 for s in g["boxSize"])
+
+ # Scenario A: same path across two distinct runs must trigger a reload.
+ run_a = f"run-stale-{stamp}-a"
+ start_run(run_a)
+ release(held_for(run_a, "steps/01-foundation.glb"))
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ ga = page.evaluate(SCENE_JS)
+ results.append((ga["ready"] and is_glb(ga) and box_ok(ga),
+ f"stale-load: run A displays GLB scene (kids={ga.get('kids')})"))
+ (evidence / "stale-scene-a.json").write_text(json.dumps(ga, indent=2, ensure_ascii=False))
+
+ run_b = f"run-stale-{stamp}-b"
+ start_run(run_b)
+ eb = held_for(run_b, "steps/01-foundation.glb")
+ results.append((True, f"stale-load: same path across runs reloads for run B ({eb['run']})"))
+ release(eb)
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ gb = page.evaluate(SCENE_JS)
+ results.append((gb["ready"] and is_glb(gb) and box_ok(gb),
+ f"stale-load: run B repaints GLB after same-path switch (kids={gb.get('kids')})"))
+ page.screenshot(path=str(evidence / "stale-page-b.png"), full_page=True)
+
+ # Scenario B: late success from a superseded run must be ignored.
+ run_c = f"run-stale-{stamp}-c"
+ start_run(run_c)
+ held_for(run_c, "steps/01-foundation.glb")
+
+ run_d = f"run-stale-{stamp}-d"
+ start_run(run_d)
+ frame_geo = None
+ deadline = time.time() + 30
+ while time.time() < deadline:
+ g = page.evaluate(SCENE_JS)
+ if g["ready"] and is_frame(g):
+ frame_geo = g
+ break
+ page.wait_for_timeout(300)
+ results.append((frame_geo is not None,
+ "stale-load: run switch with empty steps repaints replay frame mesh "
+ f"({(frame_geo or {}).get('kids')})"))
+ page.screenshot(path=str(evidence / "stale-page-d-frame.png"), full_page=True)
+
+ release(held_for(run_c, "steps/01-foundation.glb"))
+ page.wait_for_timeout(600)
+ g2 = page.evaluate(SCENE_JS)
+ results.append((g2["ready"] and is_frame(g2)
+ and g2["note"] != "管线几何 · steps/01-foundation.glb",
+ f"stale-load: late old success ignored (note='{g2['note'][:40]}', "
+ f"kids={g2['kids']})"))
+
+ release(held_for(run_d, "steps/01-foundation.glb"))
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ g3 = page.evaluate(SCENE_JS)
+ results.append((g3["ready"] and is_glb(g3) and box_ok(g3),
+ "stale-load: run D current load displays after late-old-success"))
+
+ # Scenario C: late error from a superseded run must not fail the current load.
+ run_e = f"run-stale-{stamp}-e"
+ start_run(run_e)
+ ee = held_for(run_e, "steps/01-foundation.glb")
+
+ run_f = f"run-stale-{stamp}-f"
+ start_run(run_f)
+ ef = held_for(run_f, "steps/01-foundation.glb")
+
+ release(ee, status=500)
+ page.wait_for_timeout(600)
+ g4 = page.evaluate(SCENE_JS)
+ results.append(("加载失败" not in g4["note"],
+ f"stale-load: late old error ignored (note='{g4['note'][:40]}')"))
+
+ release(ef)
+ wait_text(page, "#viewport-note", "管线几何 · steps/01-foundation.glb")
+ page.wait_for_timeout(600)
+ g5 = page.evaluate(SCENE_JS)
+ results.append((g5["ready"] and is_glb(g5) and box_ok(g5) and "加载失败" not in g5["note"],
+ "stale-load: current owner load unaffected by stale error"))
+ (evidence / "stale-scene-final.json").write_text(json.dumps(g5, indent=2, ensure_ascii=False))
+ page.screenshot(path=str(evidence / "stale-page-final.png"), full_page=True)
+ page.close()
+
+
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", type=int, default=18201)
@@ -274,8 +458,11 @@ def main():
with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--enable-unsafe-swiftshader"])
- def track(page):
- page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
+ def track(page, ignore=None):
+ def on_console(m):
+ if m.type == "error" and not (ignore and ignore(m)):
+ console_errors.append(m.text)
+ page.on("console", on_console)
page.on("pageerror", lambda e: console_errors.append(str(e)))
desktop_ms = int(time.time() * 1000)
@@ -302,6 +489,8 @@ def main():
expected = REFERENCE_SHA[step_path]
results.append((digest == expected, f"sha256 {step_path} -> {digest[:12]}… (expect {expected[:12]}…)"))
+ stale_load_regression(browser, base_url, track, results, evidence)
+
fail_ms = int(time.time() * 1000) + 2
fail_run_id = f"run-acc-{fail_ms}-failure"
(artifact_root / project_id / fail_run_id).mkdir(parents=True, exist_ok=True)