From a63f34d073ae8898539f8acbcda0a8a4a7b4de03 Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Wed, 8 Apr 2026 03:01:14 +0000 Subject: [PATCH 1/6] web: add self-contained static HTML timing report (web_save_report) Add a `web_save_report` Tcl command that generates a standalone HTML timing report reusing the exact same JS/CSS as the live web viewer. Architecture: The static report substitutes the WebSocket server with a cache layer. WebSocketManager gains a `fromCache(cache)` factory that serves pre-computed responses from `window.__STATIC_CACHE__` embedded in the HTML. Cached request types include tech, bounds, timing_report, slack_histogram, chart_filters, and tile PNGs. Uncached requests (select, tcl_eval, etc.) reject gracefully. All 18 JS source files plus a vendored GoldenLayout bundle are concatenated by embed_report_assets.py into a single + + + + + + +
+
+ + + + + +)"; + + out.close(); + logger_->info(utl::WEB, 32, "Saved timing report to {}", filename); +} + void WebServer::saveImage(const std::string& filename, const int x0, const int y0, diff --git a/src/web/src/web.i b/src/web/src/web.i index 16f83115d21..9d6a718fbca 100644 --- a/src/web/src/web.i +++ b/src/web/src/web.i @@ -30,6 +30,14 @@ save_image_cmd(const char* filename, vis_json ? vis_json : ""); } +void +save_report_cmd(const char* filename, + int max_setup, int max_hold) +{ + web::WebServer *server = ord::OpenRoad::openRoad()->getWebServer(); + server->saveReport(filename, max_setup, max_hold); +} + } // namespace web %} // inline diff --git a/src/web/src/web.tcl b/src/web/src/web.tcl index 10954f84b8e..8dac9bc53d1 100644 --- a/src/web/src/web.tcl +++ b/src/web/src/web.tcl @@ -126,3 +126,30 @@ proc save_image { args } { rename $options "" } } + +sta::define_cmd_args "web_save_report" {[-setup_paths count] \ + [-hold_paths count] \ + path +} + +proc web_save_report { args } { + sta::parse_key_args "web_save_report" args \ + keys {-setup_paths -hold_paths} flags {} + + set max_setup 100 + if { [info exists keys(-setup_paths)] } { + sta::check_positive_int "-setup_paths" $keys(-setup_paths) + set max_setup $keys(-setup_paths) + } + + set max_hold 100 + if { [info exists keys(-hold_paths)] } { + sta::check_positive_int "-hold_paths" $keys(-hold_paths) + set max_hold $keys(-hold_paths) + } + + sta::check_argc_eq1 "web_save_report" $args + set path [lindex $args 0] + + web::save_report_cmd $path $max_setup $max_hold +} diff --git a/src/web/src/websocket-manager.js b/src/web/src/websocket-manager.js index 64ab0f3e826..a1dccd5d0e7 100644 --- a/src/web/src/websocket-manager.js +++ b/src/web/src/websocket-manager.js @@ -2,6 +2,8 @@ // Copyright (c) 2026, The OpenROAD Authors // WebSocket manager with request/response tracking and auto-reconnect. +// Supports a cache mode for static reports where pre-computed responses +// are served from window.__STATIC_CACHE__ without a WebSocket connection. export class WebSocketManager { constructor(url, onStatusChange) { @@ -14,9 +16,30 @@ export class WebSocketManager { this.readyResolve = null; this.onStatusChange = onStatusChange || (() => {}); this.onPush = null; // callback for server-push notifications + this._cache = null; this.connect(); } + // Create a cache-backed instance (no WebSocket connection). + static fromCache(cache, onStatusChange) { + const mgr = Object.create(WebSocketManager.prototype); + mgr.url = null; + mgr.socket = null; + mgr.nextId = 1; + mgr.pending = new Map(); + mgr.reconnectDelay = 0; + mgr.onStatusChange = onStatusChange || (() => {}); + mgr.onPush = null; + mgr._cache = cache; + mgr.readyPromise = Promise.resolve(); + mgr.readyResolve = null; + return mgr; + } + + get isStaticMode() { + return !!this._cache; + } + connect() { this.readyPromise = new Promise(resolve => { this.readyResolve = resolve; @@ -89,6 +112,9 @@ export class WebSocketManager { } request(msg) { + if (this._cache) { + return this._cacheRequest(msg); + } const id = this.nextId++; msg.id = id; const promise = new Promise((resolve, reject) => { @@ -108,4 +134,49 @@ export class WebSocketManager { this.pending.delete(id); this.onStatusChange(); } + + // Serve a request from the embedded cache. + _cacheRequest(msg) { + const type = msg.type; + + // Tile requests — return a data URI string. + if (type === 'tile') { + const key = msg.layer + '/' + msg.z + '/' + msg.x + '/' + msg.y; + const b64 = this._cache.tiles && this._cache.tiles[key]; + if (b64) { + return Promise.resolve('data:image/png;base64,' + b64); + } + return Promise.reject(new Error('Tile not cached: ' + key)); + } + + // Timing highlight — drive the overlay image. + if (type === 'timing_highlight') { + const idx = msg.path_index; + if (idx >= 0 && this._cache.overlays) { + const side = msg.is_setup ? 'setup' : 'hold'; + const b64 = this._cache.overlays[side]?.[idx]; + if (this._cache.setPathOverlay) { + this._cache.setPathOverlay( + b64 ? 'data:image/png;base64,' + b64 : null); + } + } else if (this._cache.setPathOverlay) { + this._cache.setPathOverlay(null); + } + return Promise.resolve({}); + } + + // Parameterized JSON lookups. + let key = type; + if (type === 'timing_report') { + key = 'timing_report:' + (msg.is_setup ? 'setup' : 'hold'); + } else if (type === 'slack_histogram') { + key = 'slack_histogram:' + (msg.is_setup ? 'setup' : 'hold'); + } + const json = this._cache.json && this._cache.json[key]; + if (json !== undefined) { + return Promise.resolve(json); + } + + return Promise.reject(new Error('Not available in static mode: ' + type)); + } } diff --git a/src/web/src/websocket-tile-layer.js b/src/web/src/websocket-tile-layer.js index daee657a623..9d9163ba865 100644 --- a/src/web/src/websocket-tile-layer.js +++ b/src/web/src/websocket-tile-layer.js @@ -46,8 +46,12 @@ export function createWebSocketTileLayer(visibility) { x: coords.x, y: coords.y, ...vf, - }).then(blob => { - tile.src = URL.createObjectURL(blob); + }).then(data => { + if (typeof data === 'string') { + tile.src = data; // data URI from cache + } else { + tile.src = URL.createObjectURL(data); + } }).catch(() => { // Request was cancelled (e.g. by refreshTiles); ignore }); @@ -87,11 +91,15 @@ export function createWebSocketTileLayer(visibility) { x: coords.x, y: coords.y, ...vf, - }).then(blob => { + }).then(data => { if (tile.src && tile.src.startsWith('blob:')) { URL.revokeObjectURL(tile.src); } - tile.src = URL.createObjectURL(blob); + if (typeof data === 'string') { + tile.src = data; + } else { + tile.src = URL.createObjectURL(data); + } }).catch(() => { // Tile refresh failed; keep existing image }); diff --git a/src/web/test/BUILD b/src/web/test/BUILD index ff0c92de543..a0e57e0f4e9 100644 --- a/src/web/test/BUILD +++ b/src/web/test/BUILD @@ -216,3 +216,24 @@ cc_test( "@googletest//:gtest_main", ], ) + +cc_test( + name = "save_report_test", + srcs = ["cpp/TestSaveReport.cpp"], + copts = ["-Isrc/web/src"], + data = [ + "//test:nangate45_data", + ], + features = ["-layering_check"], + deps = [ + "//src/gui", + "//src/odb", + "//src/tst", + "//src/tst:nangate45_fixture", + "//src/utl", + "//src/web", + "//third-party/lodepng", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) diff --git a/src/web/test/cpp/CMakeLists.txt b/src/web/test/cpp/CMakeLists.txt index 87f5b0bee1e..e10554e8fe1 100644 --- a/src/web/test/cpp/CMakeLists.txt +++ b/src/web/test/cpp/CMakeLists.txt @@ -36,7 +36,21 @@ gtest_discover_tests(TestRequestHandler WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. ) +add_executable(TestSaveReport TestSaveReport.cpp) + +target_link_libraries(TestSaveReport ${TEST_LIBS}) + +target_include_directories(TestSaveReport + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../src +) + +gtest_discover_tests(TestSaveReport + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. +) + add_dependencies(build_and_test TestTileGenerator TestRequestHandler + TestSaveReport ) diff --git a/src/web/test/cpp/TestRequestHandler.cpp b/src/web/test/cpp/TestRequestHandler.cpp index 83b04e3f428..b6314c05b9c 100644 --- a/src/web/test/cpp/TestRequestHandler.cpp +++ b/src/web/test/cpp/TestRequestHandler.cpp @@ -382,9 +382,11 @@ class SelectHandlerTest : public tst::Nangate45Fixture block_->setDieArea(odb::Rect(0, 0, 100000, 100000)); block_->setCoreArea(odb::Rect(0, 0, 100000, 100000)); placeInst("BUF_X16", "buf1", 0, 0); - fake_current_ = {"current", "FakeCurrent", odb::Rect(0, 0, 100, 100)}; - fake_previous_ - = {"previous", "FakePrevious", odb::Rect(100, 100, 200, 200)}; + fake_current_ + = {.name = "current", .type = "FakeCurrent", .bbox = {0, 0, 100, 100}}; + fake_previous_ = {.name = "previous", + .type = "FakePrevious", + .bbox = {100, 100, 200, 200}}; gen_ = std::make_shared( getDb(), /*sta=*/nullptr, getLogger()); tcl_eval_ = std::make_shared(/*interp=*/nullptr, getLogger()); diff --git a/src/web/test/cpp/TestSaveReport.cpp b/src/web/test/cpp/TestSaveReport.cpp new file mode 100644 index 00000000000..a48a9a17b36 --- /dev/null +++ b/src/web/test/cpp/TestSaveReport.cpp @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "json_builder.h" +#include "odb/db.h" +#include "odb/dbTypes.h" +#include "tile_generator.h" +#include "timing_report.h" +#include "tst/nangate45_fixture.h" +#include "web/web.h" + +namespace web { +namespace { + +// ─── Fixture ──────────────────────────────────────────────────────────────── + +class SaveReportTest : public tst::Nangate45Fixture +{ + protected: + void SetUp() override + { + block_->setDieArea(odb::Rect(0, 0, 20000, 20000)); + // Place several instances to ensure non-empty tiles. + for (int i = 0; i < 10; ++i) { + placeInst("BUF_X16", + ("buf" + std::to_string(i)).c_str(), + 2000 + i * 1500, + 2000 + i * 1500); + } + } + + void TearDown() override + { + for (const auto& path : output_files_) { + std::filesystem::remove(path); + } + } + + odb::dbInst* placeInst(const char* master_name, + const char* inst_name, + int x, + int y) + { + odb::dbMaster* master = lib_->findMaster(master_name); + EXPECT_NE(master, nullptr) << "Master not found: " << master_name; + odb::dbInst* inst = odb::dbInst::create(block_, master, inst_name); + inst->setLocation(x, y); + inst->setPlacementStatus(odb::dbPlacementStatus::PLACED); + return inst; + } + + std::string tempHtml(const std::string& label) + { + std::string path = std::filesystem::temp_directory_path() + / ("web_test_" + label + ".html"); + output_files_.push_back(path); + return path; + } + + static std::string readFile(const std::string& path) + { + std::ifstream f(path); + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); + } + + // Generate a report (no STA — timing sections will be empty but valid). + void generateReport(const std::string& path, + int max_setup = 0, + int max_hold = 0) + { + WebServer server(getDb(), /*sta=*/nullptr, getLogger(), /*interp=*/nullptr); + server.saveReport(path, max_setup, max_hold); + } + + // Count occurrences of a substring in a string. + static int countOccurrences(const std::string& haystack, + const std::string& needle) + { + int count = 0; + size_t pos = 0; + while ((pos = haystack.find(needle, pos)) != std::string::npos) { + ++count; + pos += needle.size(); + } + return count; + } + + // Check if a string contains a substring. + static bool contains(const std::string& haystack, const std::string& needle) + { + return haystack.find(needle) != std::string::npos; + } + + std::vector output_files_; +}; + +// ─── HTML Structure ───────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, GeneratesFile) +{ + const std::string path = tempHtml("generates"); + generateReport(path); + ASSERT_TRUE(std::filesystem::exists(path)); + EXPECT_GT(std::filesystem::file_size(path), 0u); +} + +TEST_F(SaveReportTest, ContainsRequiredHTMLElements) +{ + const std::string path = tempHtml("html_elements"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "id=\"gl-container\"")); + EXPECT_TRUE(contains(html, "id=\"menu-bar\"")); + EXPECT_TRUE(contains(html, "id=\"loading-overlay\"")); + EXPECT_TRUE(contains(html, "leaflet.css")); + EXPECT_TRUE(contains(html, "goldenlayout-base.css")); +} + +TEST_F(SaveReportTest, ContainsStaticCache) +{ + const std::string path = tempHtml("static_cache"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "window.__STATIC_CACHE__")); +} + +TEST_F(SaveReportTest, ContainsInlinedJS) +{ + const std::string path = tempHtml("inlined_js"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "class WebSocketManager")); + EXPECT_TRUE(contains(html, "fromCache")); + EXPECT_TRUE(contains(html, "TimingWidget")); + EXPECT_TRUE(contains(html, "ChartsWidget")); +} + +TEST_F(SaveReportTest, GoldenLayoutFromCDN) +{ + const std::string path = tempHtml("gl_cdn"); + generateReport(path); + const std::string html = readFile(path); + + // GoldenLayout loaded via ES module import from CDN. + EXPECT_TRUE(contains(html, "type=\"module\"")); + EXPECT_TRUE(contains(html, "esm.sh/golden-layout")); + // No vendored golden-layout bundle in the HTML. + EXPECT_FALSE(contains(html, "goldenlayout.umd")); +} + +// ─── Cache JSON Responses ─────────────────────────────────────────────────── + +TEST_F(SaveReportTest, CachesValidTechResponse) +{ + const std::string path = tempHtml("tech"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"tech\":")); + EXPECT_TRUE(contains(html, "\"layers\":")); + EXPECT_TRUE(contains(html, "\"has_liberty\":")); + EXPECT_TRUE(contains(html, "\"dbu_per_micron\":")); +} + +TEST_F(SaveReportTest, CachesValidBoundsResponse) +{ + const std::string path = tempHtml("bounds"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"bounds\":")); + EXPECT_TRUE(contains(html, "\"shapes_ready\": true")); + EXPECT_TRUE(contains(html, "\"pin_max_size\":")); +} + +TEST_F(SaveReportTest, CachesTimingReportJSON) +{ + const std::string path = tempHtml("timing"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"timing_report:setup\":")); + EXPECT_TRUE(contains(html, "\"timing_report:hold\":")); + // Both should have paths arrays (even if empty without STA). + EXPECT_GE(countOccurrences(html, "\"paths\":"), 2); +} + +TEST_F(SaveReportTest, CachesSlackHistogramJSON) +{ + const std::string path = tempHtml("histogram"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"slack_histogram:setup\":")); + EXPECT_TRUE(contains(html, "\"slack_histogram:hold\":")); +} + +TEST_F(SaveReportTest, CachesChartFiltersJSON) +{ + const std::string path = tempHtml("filters"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"chart_filters\":")); + EXPECT_TRUE(contains(html, "\"path_groups\":")); + EXPECT_TRUE(contains(html, "\"clocks\":")); +} + +TEST_F(SaveReportTest, CachesHeatmapsEmpty) +{ + const std::string path = tempHtml("heatmaps"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"heatmaps\":")); + // Should be an empty stub. + EXPECT_TRUE(contains(html, "\"heatmaps\":[]")); +} + +// ─── Tile Cache ───────────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, CachesTilesAtCorrectZoom) +{ + const std::string path = tempHtml("tile_zoom"); + generateReport(path); + const std::string html = readFile(path); + + // Tiles should be at zoom 1 (the hardcoded default). + EXPECT_TRUE(contains(html, "zoom: 1")); + // At least some tile with /1/ zoom level should exist. + EXPECT_TRUE(contains(html, "/1/")); +} + +TEST_F(SaveReportTest, CachesTiles) +{ + const std::string path = tempHtml("tiles"); + generateReport(path); + const std::string html = readFile(path); + + // At least some tiles should be cached. + EXPECT_TRUE(contains(html, "tiles: {")); + // At least one tile key with zoom 1 should exist. + EXPECT_TRUE(contains(html, "/1/")) << "No tiles at zoom 1 found"; +} + +TEST_F(SaveReportTest, TilesAreValidBase64) +{ + const std::string path = tempHtml("tile_base64"); + generateReport(path); + const std::string html = readFile(path); + + // Find any tile value: "layer/1/x/y":"base64..." + const std::string marker = "/1/"; + auto pos = html.find(marker); + ASSERT_NE(pos, std::string::npos) << "No tile entry found"; + // Skip to the value after the key. + pos = html.find("\":\"", pos); + ASSERT_NE(pos, std::string::npos); + pos += 3; // skip ":" + // Check the base64 prefix: iVBOR is the b64 encoding of \x89PNG\r\n. + EXPECT_EQ(html.substr(pos, 4), "iVBO") << "Tile doesn't look like base64 PNG"; +} + +// ─── Overlays ─────────────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, OverlayArraysPresent) +{ + const std::string path = tempHtml("overlays"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "setup: [")); + EXPECT_TRUE(contains(html, "hold: [")); +} + +// ─── Shared Serialization ─────────────────────────────────────────────────── + +TEST_F(SaveReportTest, SerializeTimingPathsRoundTrip) +{ + TimingPathSummary p; + p.start_clk = "clk1"; + p.end_clk = "clk2"; + p.slack = -0.5f; + p.arrival = 1.0f; + p.required = 1.5f; + p.skew = 0.1f; + p.path_delay = 0.9f; + p.logic_depth = 3; + p.fanout = 5; + p.start_pin = "a/Z"; + p.end_pin = "b/D"; + + TimingNode node; + node.pin_name = "a/Z"; + node.fanout = 2; + node.is_rising = true; + node.is_clock = false; + node.time = 0.5f; + node.delay = 0.1f; + node.slew = 0.02f; + node.load = 0.01f; + p.data_nodes.push_back(node); + + std::vector paths = {p}; + JsonBuilder b; + serializeTimingPaths(b, paths); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"clk1\"")); + EXPECT_TRUE(contains(json, "\"clk2\"")); + EXPECT_TRUE(contains(json, "\"a/Z\"")); + EXPECT_TRUE(contains(json, "\"paths\":")); + EXPECT_TRUE(contains(json, "\"data_nodes\":")); + EXPECT_TRUE(contains(json, "\"capture_nodes\":")); +} + +TEST_F(SaveReportTest, SerializeSlackHistogramRoundTrip) +{ + SlackHistogramResult h; + h.bins.push_back({-1.0f, 0.0f, 10, true}); + h.bins.push_back({0.0f, 1.0f, 20, false}); + h.unconstrained_count = 5; + h.total_endpoints = 35; + h.time_unit = "ns"; + + JsonBuilder b; + serializeSlackHistogram(b, h); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"bins\":")); + EXPECT_TRUE(contains(json, "\"unconstrained_count\": 5")); + EXPECT_TRUE(contains(json, "\"total_endpoints\": 35")); + EXPECT_TRUE(contains(json, "\"time_unit\": \"ns\"")); +} + +TEST_F(SaveReportTest, SerializeChartFiltersRoundTrip) +{ + ChartFilters f; + f.path_groups.emplace_back("default"); + f.clocks.emplace_back("clk"); + + JsonBuilder b; + serializeChartFilters(b, f); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"path_groups\":")); + EXPECT_TRUE(contains(json, "\"default\"")); + EXPECT_TRUE(contains(json, "\"clocks\":")); + EXPECT_TRUE(contains(json, "\"clk\"")); +} + +TEST_F(SaveReportTest, SerializeTechResponse) +{ + TileGenerator gen(getDb(), /*sta=*/nullptr, getLogger()); + JsonBuilder b; + serializeTechResponse(b, gen); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"layers\":")); + EXPECT_TRUE(contains(json, "\"sites\":")); + EXPECT_TRUE(contains(json, "\"has_liberty\":")); + EXPECT_TRUE(contains(json, "\"dbu_per_micron\":")); +} + +TEST_F(SaveReportTest, SerializeBoundsShapesReady) +{ + TileGenerator gen(getDb(), /*sta=*/nullptr, getLogger()); + + JsonBuilder b_true; + serializeBoundsResponse(b_true, gen, true); + EXPECT_TRUE(contains(b_true.str(), "\"shapes_ready\": true")); + + JsonBuilder b_false; + serializeBoundsResponse(b_false, gen, false); + EXPECT_TRUE(contains(b_false.str(), "\"shapes_ready\": false")); +} + +// ─── Edge Cases ───────────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, ZeroPathsReport) +{ + const std::string path = tempHtml("zero_paths"); + generateReport(path, 0, 0); + const std::string html = readFile(path); + + ASSERT_TRUE(std::filesystem::exists(path)); + EXPECT_TRUE(contains(html, "\"paths\": []")); +} + +} // namespace +} // namespace web diff --git a/src/web/test/js/test-websocket-manager.js b/src/web/test/js/test-websocket-manager.js index 3f4d303e739..edb056538ae 100644 --- a/src/web/test/js/test-websocket-manager.js +++ b/src/web/test/js/test-websocket-manager.js @@ -110,3 +110,122 @@ describe('WebSocketManager', () => { }); }); }); + +describe('WebSocketManager.fromCache', () => { + function makeCache(overrides = {}) { + return { + zoom: 1, + json: { + tech: { layers: ['M1'], sites: [], has_liberty: false, dbu_per_micron: 1000 }, + bounds: { bounds: [[0, 0], [100, 100]], shapes_ready: true }, + heatmaps: { active: '', heatmaps: [] }, + 'timing_report:setup': { paths: [{ slack: -0.1 }] }, + 'timing_report:hold': { paths: [{ slack: 0.2 }] }, + 'slack_histogram:setup': { bins: [], total_endpoints: 0 }, + chart_filters: { path_groups: [], clocks: [] }, + }, + tiles: { + 'M1/1/0/0': 'iVBORw0KGgo=', + }, + overlays: { + setup: ['base64data'], + hold: [null], + }, + setPathOverlay: null, + ...overrides, + }; + } + + it('creates instance without WebSocket', () => { + const mgr = WebSocketManager.fromCache(makeCache()); + assert.equal(mgr.isStaticMode, true); + assert.equal(mgr.socket, null); + }); + + it('readyPromise resolves immediately', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + await mgr.readyPromise; // Should not hang. + }); + + it('returns cached JSON for tech', async () => { + const cache = makeCache(); + const mgr = WebSocketManager.fromCache(cache); + const result = await mgr.request({ type: 'tech' }); + assert.deepEqual(result.layers, ['M1']); + }); + + it('returns cached JSON for bounds', async () => { + const cache = makeCache(); + const mgr = WebSocketManager.fromCache(cache); + const result = await mgr.request({ type: 'bounds' }); + assert.equal(result.shapes_ready, true); + }); + + it('returns setup timing report when is_setup=1', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'timing_report', is_setup: 1 }); + assert.equal(result.paths[0].slack, -0.1); + }); + + it('returns hold timing report when is_setup=0', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'timing_report', is_setup: 0 }); + assert.equal(result.paths[0].slack, 0.2); + }); + + it('returns setup histogram', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'slack_histogram', is_setup: 1 }); + assert.equal(result.total_endpoints, 0); + }); + + it('returns chart_filters', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'chart_filters' }); + assert.ok(Array.isArray(result.path_groups)); + }); + + it('rejects uncached request types', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + await assert.rejects( + mgr.request({ type: 'select' }), + { message: /Not available in static mode/ } + ); + }); + + it('returns data URI string for cached tile', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ + type: 'tile', layer: 'M1', z: 1, x: 0, y: 0 + }); + assert.equal(typeof result, 'string'); + assert.ok(result.startsWith('data:image/png;base64,')); + }); + + it('rejects uncached tile', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + await assert.rejects( + mgr.request({ type: 'tile', layer: 'M1', z: 1, x: 9, y: 9 }), + { message: /Tile not cached/ } + ); + }); + + it('timing_highlight calls setPathOverlay with data URI', async () => { + let called = null; + const cache = makeCache(); + cache.setPathOverlay = (v) => { called = v; }; + const mgr = WebSocketManager.fromCache(cache); + await mgr.request({ type: 'timing_highlight', path_index: 0, is_setup: 1 }); + assert.ok(called !== null); + assert.ok(called.startsWith('data:image/png;base64,')); + }); + + it('timing_highlight with index -1 clears overlay', async () => { + let called = 'not_called'; + const cache = makeCache(); + cache.setPathOverlay = (v) => { called = v; }; + const mgr = WebSocketManager.fromCache(cache); + await mgr.request({ type: 'timing_highlight', path_index: -1 }); + assert.equal(called, null); + }); +}); From dc5ff0dccd0d260c5887a957a84d66511c382514 Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Wed, 8 Apr 2026 18:59:11 +0000 Subject: [PATCH 2/6] web: harden saveReport with early file check, escaping, and encoding - Open output file before expensive rendering to fail fast on bad paths - Escape tile cache keys with json_escape() for valid JavaScript output - Specify encoding='utf-8' in embed_report_assets.py for portability Signed-off-by: Matt Liberty --- src/web/src/embed_report_assets.py | 6 +++--- src/web/src/web.cpp | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/web/src/embed_report_assets.py b/src/web/src/embed_report_assets.py index 0115250cf38..709f53bea96 100755 --- a/src/web/src/embed_report_assets.py +++ b/src/web/src/embed_report_assets.py @@ -104,17 +104,17 @@ def main(): # Read and process JS files. js_parts = [] for path in args.js: - with open(path) as f: + with open(path, encoding="utf-8") as f: content = f.read() js_parts.append(f'// ── {path.split("/")[-1]} ──') js_parts.append(process_js_file(content)) combined_js = "\n".join(js_parts) # Read CSS. - with open(args.css) as f: + with open(args.css, encoding="utf-8") as f: css_content = f.read() - with open(args.output, "w") as out: + with open(args.output, "w", encoding="utf-8") as out: out.write("// Auto-generated — do not edit.\n") out.write("#include \n") out.write("namespace web {\n") diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp index 8a24efbdc02..76340c593a1 100644 --- a/src/web/src/web.cpp +++ b/src/web/src/web.cpp @@ -1050,6 +1050,12 @@ void WebServer::saveReport(const std::string& filename, return; } + std::ofstream out(filename); + if (!out) { + logger_->error(utl::WEB, 31, "Cannot open file: {}", filename); + return; + } + // ── Serialize JSON cache responses ── std::string setup_json, hold_json, hist_setup, hist_hold, filters; @@ -1156,12 +1162,6 @@ void WebServer::saveReport(const std::string& filename, // ── Write the HTML ── - std::ofstream out(filename); - if (!out) { - logger_->error(utl::WEB, 31, "Cannot open file: {}", filename); - return; - } - // HTML head — same CDN deps as index.html. out << R"( @@ -1216,7 +1216,7 @@ window.__STATIC_CACHE__ = { if (i > 0) { out << ","; } - out << "\n \"" << tile_entries[i].first << "\":\"" + out << "\n \"" << json_escape(tile_entries[i].first) << "\":\"" << tile_entries[i].second << "\""; } From 64e3b44aef9396b07fbd5aad7baaf9705b3ce7a0 Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Mon, 13 Apr 2026 00:22:19 +0000 Subject: [PATCH 3/6] web: remove unused npm_link_all_packages from BUILD The npm_link_all_packages call fails because the pnpm root package is src/web/test, not src/web. The node_modules target was never referenced as a dependency, so remove it. Signed-off-by: Matt Liberty --- src/web/BUILD | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/web/BUILD b/src/web/BUILD index e37cce1d4ff..c60d6570492 100644 --- a/src/web/BUILD +++ b/src/web/BUILD @@ -1,7 +1,6 @@ # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2026, The OpenROAD Authors -load("@npm//:defs.bzl", "npm_link_all_packages") load("@rules_cc//cc:cc_library.bzl", "cc_library") load("@rules_python//python:defs.bzl", "py_binary") load("//bazel:tcl_encode_or.bzl", "tcl_encode") @@ -12,8 +11,6 @@ package( features = ["layering_check"], ) -npm_link_all_packages(name = "node_modules") - py_binary( name = "embed_report_assets", srcs = ["src/embed_report_assets.py"], From 455e1af6e8dc56be321741eee291beb17548c3ab Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Mon, 13 Apr 2026 00:36:35 +0000 Subject: [PATCH 4/6] web: fix schematic port_directions and dark mode visibility Add missing field(std::string, const char*) overload to JsonBuilder. Without it, field(getName(), ioTypeToDirection()) resolved to the bool overload (standard pointer-to-bool conversion beats user-defined const char*-to-string), writing true instead of "input"/"output"/"inout". Invert netlistsvg SVG colors in dark mode so strokes and text are visible against the dark background. Signed-off-by: Matt Liberty --- src/web/src/json_builder.h | 4 ++++ src/web/src/style.css | 7 +++++++ src/web/test/cpp/TestRequestHandler.cpp | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/web/src/json_builder.h b/src/web/src/json_builder.h index 565f033ed5b..4a3b85353dd 100644 --- a/src/web/src/json_builder.h +++ b/src/web/src/json_builder.h @@ -155,6 +155,10 @@ class JsonBuilder buf_ += val ? "true" : "false"; } + void field(const std::string& key, const char* val) + { + field(key.c_str(), val); + } void field(const std::string& key, int val) { field(key.c_str(), val); } void field(const std::string& key, float val) { field(key.c_str(), val); } void field(const std::string& key, double val) { field(key.c_str(), val); } diff --git a/src/web/src/style.css b/src/web/src/style.css index 68f71b387af..16018b7d0d5 100644 --- a/src/web/src/style.css +++ b/src/web/src/style.css @@ -1214,3 +1214,10 @@ html, body { flex-direction: column; gap: 4px; } + +/* ─── Schematic Widget ────────────────────────────────────────────────────── */ + +/* netlistsvg renders dark strokes/text on white fills — invert for dark mode */ +html[data-theme="dark"] .schematic-widget svg { + filter: invert(1) hue-rotate(180deg); +} diff --git a/src/web/test/cpp/TestRequestHandler.cpp b/src/web/test/cpp/TestRequestHandler.cpp index b6314c05b9c..02489759aad 100644 --- a/src/web/test/cpp/TestRequestHandler.cpp +++ b/src/web/test/cpp/TestRequestHandler.cpp @@ -126,6 +126,24 @@ TEST(JsonEscapeTest, ControlChars) EXPECT_EQ(json_escape(input), "\\u0001"); } +//------------------------------------------------------------------------------ +// JsonBuilder overload tests +//------------------------------------------------------------------------------ + +// Verify that field(std::string, const char*) writes a JSON string, not a bool. +// Without an explicit overload, C++ prefers the standard pointer-to-bool +// conversion over the user-defined const char*-to-std::string conversion, +// silently writing "true" instead of the intended string value. +TEST(JsonBuilderTest, FieldStringKeyCharPtrValueWritesString) +{ + JsonBuilder b; + b.beginObject(); + b.field(std::string("dir"), "input"); + b.endObject(); + EXPECT_NE(b.str().find("\"input\""), std::string::npos); + EXPECT_EQ(b.str().find("true"), std::string::npos); +} + //------------------------------------------------------------------------------ // dispatch_request tests (BOUNDS, LAYERS, INFO) //------------------------------------------------------------------------------ From 95bfbab53d536a53724e453615b95895f71374fe Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Mon, 13 Apr 2026 02:28:31 +0000 Subject: [PATCH 5/6] web: buildifier Signed-off-by: Matt Liberty --- src/web/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/BUILD b/src/web/BUILD index c60d6570492..35cbea50aad 100644 --- a/src/web/BUILD +++ b/src/web/BUILD @@ -64,6 +64,7 @@ genrule( " $(location src/main.js)", tools = [":embed_report_assets"], ) + cc_library( name = "web", srcs = [ From 4aa5e2f927b1ba8df26992ba1449620ef1373893 Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Mon, 13 Apr 2026 23:12:21 +0000 Subject: [PATCH 6/6] web: uniquify temp file paths in tests to avoid PID-collision races tempHtml/tempPng wrote to /tmp/web_test_