From 822c092790bad4b1c0d780c1fafee063d70c91f6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 02:59:53 +0800 Subject: fix(viewer): guard step loads by generation and run identity, add stale-load regression --- README.md | 10 +- public/App.js | 132 ++++++++++++++++++--- src/SomhairlesDream.Frontend/App.fs | 89 +++++++++++--- src/SomhairlesDream.Frontend/Bindings.fs | 4 + tools/acceptance/e2e_acceptance.py | 195 ++++++++++++++++++++++++++++++- 5 files changed, 389 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 89c962c..09f6d2a 100644 --- a/README.md +++ b/README.md @@ -188,11 +188,17 @@ dotnet run --project src/SomhairlesDream.Cli -- run \ reference run; a failure run (pre-existing run directory) shows status `失败` with the error surfaced in `run-message`; zero console errors on all pages. -- Browser acceptance driver (see above), 45/45 checks green on desktop and +- Browser acceptance driver (see above), 53/53 checks green on desktop and narrow viewports: framing asserted numerically from the projected scene bounding box (not by trusting whole-canvas hashes alone), frozen screenshots byte-deterministic, GLB hash equality, failure surfacing, - zero console errors. The frontend refits the camera to the loaded model + zero console errors. A stale-load regression intercepts step GLB + responses and fulfills them late, reordered, or with errors across six + runs, asserting via actual scene geometry (`THREE.Object3D` types/colors) + and the visible note that superseded runs' responses never repaint the + viewport, same-path reruns still reload, run switches to empty state + fall back to the replay frame, and failed current loads retry. The + frontend refits the camera to the loaded model (`THREE.Box3`-based framing) whenever the canvas resizes, and a resize never reverts the viewport to replay placeholder geometry. - NOT verified: production deployment of the old site remains untouched; diff --git a/public/App.js b/public/App.js index f953832..4b842d1 100644 --- a/public/App.js +++ b/public/App.js @@ -1,5 +1,5 @@ -import { Record } from "./fable_modules/fable-library-js.4.24.0/Types.js"; -import { class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js"; +import { Union, Record } from "./fable_modules/fable-library-js.4.24.0/Types.js"; +import { union_type, class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js"; import { Replay_frames } from "./SomhairlesDream.Shared/Replay.js"; import { equals, createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js"; import { tryFind, item } from "./fable_modules/fable-library-js.4.24.0/Array.js"; @@ -40,6 +40,34 @@ export function ViewState_$reflection() { return record_type("SomhairlesDream.Frontend.App.ViewState", [], ViewState, () => [["Renderer", string_type], ["Scene", string_type], ["Camera", string_type], ["Group", string_type], ["Canvas", class_type("Browser.Types.HTMLCanvasElement", undefined)]]); } +export class LoadOwner extends Record { + constructor(ProjectId, RunId, Path) { + super(); + this.ProjectId = ProjectId; + this.RunId = RunId; + this.Path = Path; + } +} + +export function LoadOwner_$reflection() { + return record_type("SomhairlesDream.Frontend.App.LoadOwner", [], LoadOwner, () => [["ProjectId", string_type], ["RunId", string_type], ["Path", string_type]]); +} + +export class ViewMode extends Union { + constructor(tag, fields) { + super(); + this.tag = tag; + this.fields = fields; + } + cases() { + return ["FrameMode", "StepMode"]; + } +} + +export function ViewMode_$reflection() { + return union_type("SomhairlesDream.Frontend.App.ViewMode", [], ViewMode, () => [[], []]); +} + export const frames = Replay_frames(); export let viewState = createAtom(undefined); @@ -56,7 +84,13 @@ export let currentProjectId = createAtom(""); export let currentRunId = createAtom(""); -export let loadedStepPath = createAtom(undefined); +export let loadGeneration = createAtom(0); + +export let pendingLoad = createAtom(undefined); + +export let loadedStep = createAtom(undefined); + +export let viewMode = createAtom(new ViewMode(0, [])); export let displayedNode = createAtom(undefined); @@ -173,12 +207,27 @@ export function addMesh(view, frame) { view.Group.add(mesh); } +export function clearGroup(group) { + const array = group.children; + array.forEach((node) => { + node.traverse(function (o) { if (o.geometry && o.geometry.dispose) { o.geometry.dispose(); } var mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mats.forEach(function (m) { ['map','normalMap','roughnessMap','metalnessMap','aoMap','emissiveMap'].forEach(function (key) { var t = m[key]; if (t && t.dispose) { t.dispose(); } }); if (m.dispose) { m.dispose(); } }); }); + }); + group.clear(); +} + +export function invalidateLoads() { + loadGeneration(loadGeneration() + 1); + pendingLoad(undefined); +} + export function renderFrame(frame) { selectedFrame(frame); displayedNode(undefined); + invalidateLoads(); + viewMode(new ViewMode(0, [])); if (viewState() != null) { const view = viewState(); - view.Group.clear(); + clearGroup(view.Group); addMesh(view, frame); view.Renderer.render(view.Scene, view.Camera); } @@ -218,7 +267,7 @@ export function resizeView(view) { view.Camera.aspect = (width / height); view.Camera.updateProjectionMatrix(); if (displayedNode() == null) { - renderFrame(selectedFrame()); + view.Renderer.render(view.Scene, view.Camera); } else { frameObject(view, displayedNode()); @@ -289,27 +338,67 @@ export function setManifestLink(payload) { } } -export function loadStepArtifact(view, stepPath) { - loadedStepPath(stepPath); +export function startStepLoad(view, owner) { + const generation = (loadGeneration() + 1) | 0; + loadGeneration(generation); + pendingLoad([generation, owner]); + viewMode(new ViewMode(1, [])); const loader = new GLTFLoader(); - const projectId = encodeURIComponent(currentProjectId()); - const runId = encodeURIComponent(currentRunId()); + const projectId = encodeURIComponent(owner.ProjectId); + const runId = encodeURIComponent(owner.RunId); let url; - const arg_2 = encodeURIComponent(stepPath); + const arg_2 = encodeURIComponent(owner.Path); url = toText(printf("/api/artifacts/steps?projectId=%s&runId=%s&path=%s"))(projectId)(runId)(arg_2); - setText("viewport-note", toText(printf("管线几何加载中 · %s"))(stepPath)); + setText("viewport-note", toText(printf("管线几何加载中 · %s"))(owner.Path)); loader.load(url, ((gltf) => { - view.Group.clear(); - view.Group.add(gltf.scene); - displayedNode(gltf.scene); - frameObject(view, gltf.scene); - view.Renderer.render(view.Scene, view.Camera); - setText("viewport-note", toText(printf("管线几何 · %s"))(stepPath)); + if (equals(pendingLoad(), [generation, owner])) { + pendingLoad(undefined); + clearGroup(view.Group); + view.Group.add(gltf.scene); + displayedNode(gltf.scene); + loadedStep(owner); + frameObject(view, gltf.scene); + view.Renderer.render(view.Scene, view.Camera); + setText("viewport-note", toText(printf("管线几何 · %s"))(owner.Path)); + } + else { + gltf.scene.traverse(function (o) { if (o.geometry && o.geometry.dispose) { o.geometry.dispose(); } var mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mats.forEach(function (m) { ['map','normalMap','roughnessMap','metalnessMap','aoMap','emissiveMap'].forEach(function (key) { var t = m[key]; if (t && t.dispose) { t.dispose(); } }); if (m.dispose) { m.dispose(); } }); }); + } }), null, ((_arg) => { - setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(stepPath)); + if (equals(pendingLoad(), [generation, owner])) { + pendingLoad(undefined); + loadedStep(undefined); + setText("viewport-note", toText(printf("管线几何加载失败 · %s"))(owner.Path)); + } })); } +export function loadStepArtifact(view, stepPath) { + const owner = new LoadOwner(currentProjectId(), currentRunId(), stepPath); + let matchResult, pending_1; + if (pendingLoad() != null) { + if (equals(pendingLoad()[1], owner)) { + matchResult = 0; + pending_1 = pendingLoad()[1]; + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: { + break; + } + case 1: { + startStepLoad(view, owner); + break; + } + } +} + export function updateState(payload) { let arg, arg_3; setStatusClass(statusName(payload)); @@ -317,6 +406,10 @@ export function updateState(payload) { setText("run-message", ((payload.status === "failed") && !isNullOrWhiteSpace(payload.error)) ? payload.error : payload.message); setText("project-id", payload.projectId); setText("run-id", payload.runId); + if ((currentProjectId() !== payload.projectId) ? true : (currentRunId() !== payload.runId)) { + invalidateLoads(); + displayedNode(undefined); + } currentProjectId(payload.projectId); currentRunId(payload.runId); const currentVersion = max(0, payload.currentStepIndex + 1) | 0; @@ -348,7 +441,8 @@ export function updateState(payload) { } else { const latest = item(steps.length - 1, steps); - if (!equals(loadedStepPath(), latest)) { + const owner = new LoadOwner(payload.projectId, payload.runId, latest); + if (!equals(loadedStep(), owner)) { loadStepArtifact(view, latest); } setText("mesh-label", latest); diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs index 64fb61f..6e07798 100644 --- a/src/SomhairlesDream.Frontend/App.fs +++ b/src/SomhairlesDream.Frontend/App.fs @@ -37,6 +37,15 @@ type ViewState = Group: ThreeNode Canvas: HTMLCanvasElement } +type LoadOwner = + { ProjectId: string + RunId: string + Path: string } + +type ViewMode = + | FrameMode + | StepMode + let frames = Replay.frames () let mutable viewState: ViewState option = None let mutable selectedFrame = frames[0] @@ -45,7 +54,12 @@ let mutable replayIndex = 0 let mutable eventSource: EventSource option = None let mutable currentProjectId = "" let mutable currentRunId = "" -let mutable loadedStepPath: string option = None +/// Bumped on every new step request and on every invalidation; only the +/// response matching the latest generation may repaint the viewport. +let mutable loadGeneration = 0 +let mutable pendingLoad: (int * LoadOwner) option = None +let mutable loadedStep: LoadOwner option = None +let mutable viewMode = FrameMode let mutable displayedNode: ThreeNode option = None let mutable freezeRotation = false @@ -111,14 +125,24 @@ let addMesh (view: ViewState) (frame: ReplayFrame) = let mesh = createMesh(geometry, material) view.Group.add(mesh) +let clearGroup (group: ThreeNode) = + group.children |> Array.iter disposeNodeResources + group.clear () + +let invalidateLoads () = + loadGeneration <- loadGeneration + 1 + pendingLoad <- None + let renderFrame (frame: ReplayFrame) = selectedFrame <- frame displayedNode <- None + invalidateLoads () + viewMode <- FrameMode match viewState with | None -> () | Some view -> - view.Group.clear() + clearGroup view.Group addMesh view frame view.Renderer.render(view.Scene, view.Camera) @@ -164,7 +188,7 @@ let resizeView (view: ViewState) = | Some node -> frameObject view node view.Renderer.render(view.Scene, view.Camera) - | None -> renderFrame selectedFrame + | None -> view.Renderer.render(view.Scene, view.Camera) let initializeView () = let canvas = document.getElementById("viewport-canvas") :?> HTMLCanvasElement @@ -233,23 +257,45 @@ let setManifestLink (payload: SnapshotPayload) = link.setAttribute("href", value) link.classList.remove("is-hidden") -let loadStepArtifact (view: ViewState) (stepPath: string) = - loadedStepPath <- Some stepPath +let startStepLoad (view: ViewState) (owner: LoadOwner) = + let generation = loadGeneration + 1 + loadGeneration <- generation + pendingLoad <- Some(generation, owner) + viewMode <- StepMode let loader = createGLTFLoader () - let projectId = JS.encodeURIComponent currentProjectId - let runId = JS.encodeURIComponent currentRunId - let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s" projectId runId (JS.encodeURIComponent stepPath) - setText "viewport-note" (sprintf "管线几何加载中 · %s" stepPath) + let projectId = JS.encodeURIComponent owner.ProjectId + let runId = JS.encodeURIComponent owner.RunId + let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s" projectId runId (JS.encodeURIComponent owner.Path) + setText "viewport-note" (sprintf "管线几何加载中 · %s" owner.Path) loadGLTF loader url (fun gltf -> - view.Group.clear() - view.Group.add gltf.scene - displayedNode <- Some gltf.scene - frameObject view gltf.scene - view.Renderer.render(view.Scene, view.Camera) - setText "viewport-note" (sprintf "管线几何 · %s" stepPath)) - (fun _ -> setText "viewport-note" (sprintf "管线几何加载失败 · %s" stepPath)) + if pendingLoad = Some(generation, owner) then + pendingLoad <- None + clearGroup view.Group + view.Group.add gltf.scene + displayedNode <- Some gltf.scene + loadedStep <- Some owner + frameObject view gltf.scene + view.Renderer.render(view.Scene, view.Camera) + setText "viewport-note" (sprintf "管线几何 · %s" owner.Path) + else + disposeNodeResources gltf.scene) + (fun _ -> + if pendingLoad = Some(generation, owner) then + pendingLoad <- None + loadedStep <- None + setText "viewport-note" (sprintf "管线几何加载失败 · %s" owner.Path)) + +let loadStepArtifact (view: ViewState) (stepPath: string) = + let owner = + { ProjectId = currentProjectId + RunId = currentRunId + Path = stepPath } + + match pendingLoad with + | Some (_, pending) when pending = owner -> () + | _ -> startStepLoad view owner let updateState (payload: SnapshotPayload) = let status = statusName payload @@ -265,6 +311,11 @@ let updateState (payload: SnapshotPayload) = setText "run-message" message setText "project-id" payload.projectId setText "run-id" payload.runId + + if currentProjectId <> payload.projectId || currentRunId <> payload.runId then + invalidateLoads () + displayedNode <- None + currentProjectId <- payload.projectId currentRunId <- payload.runId @@ -297,8 +348,12 @@ let updateState (payload: SnapshotPayload) = renderFrame frame else let latest = steps[steps.Length - 1] + let owner = + { ProjectId = payload.projectId + RunId = payload.runId + Path = latest } - if loadedStepPath <> Some latest then + if loadedStep <> Some owner then loadStepArtifact view latest setText "mesh-label" latest diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs index a0276cc..2822e33 100644 --- a/src/SomhairlesDream.Frontend/Bindings.fs +++ b/src/SomhairlesDream.Frontend/Bindings.fs @@ -18,6 +18,10 @@ type ThreeNode = abstract position: ThreeVector with get abstract rotation: ThreeVector with get abstract background: obj with get, set + abstract children: ThreeNode array with get + +[] +let disposeNodeResources (node: ThreeNode) : unit = jsNative [] type ThreeBox3 = 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) -- cgit v1.2.3