diff options
Diffstat (limited to 'tools')
| -rwxr-xr-x | tools/acceptance/e2e_acceptance.py | 195 |
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) |
