diff --git a/src/web/BUILD b/src/web/BUILD index f7170db4a6e..31ead270c33 100644 --- a/src/web/BUILD +++ b/src/web/BUILD @@ -2,7 +2,7 @@ # Copyright (c) 2026, The OpenROAD Authors load("@rules_cc//cc:cc_library.bzl", "cc_library") -load("@rules_python//python:defs.bzl", "py_binary") +load("@rules_python//python:defs.bzl", "py_binary", "py_test") load("//bazel:tcl_encode_or.bzl", "tcl_encode") load("//bazel:tcl_wrap_cc.bzl", "tcl_wrap_cc") load("//test:regression.bzl", "messages_txt") @@ -12,6 +12,14 @@ package( features = ["layering_check"], ) +# Read by //src/web/test:asset_list_parity_test, which holds the two build +# systems' asset lists to each other, and by the test of the report bundler. +exports_files([ + "BUILD", + "CMakeLists.txt", + "src/embed_report_assets.py", +]) + py_binary( name = "embed_report_assets", srcs = ["src/embed_report_assets.py"], @@ -24,83 +32,68 @@ py_binary( main = "src/embed_web_assets.py", ) +# The vendored libraries are generated files (see third-party/README.md); this +# is what catches one being edited in place instead of through the script. +py_test( + name = "vendor_lock_test", + srcs = ["third-party/update_vendor.py"], + args = ["--check"], + data = glob( + ["third-party/**"], + exclude = ["third-party/update_vendor.py"], + ), + legacy_create_init = 0, + main = "third-party/update_vendor.py", +) + +# The report inlines these into one + + + - - - + + + - - + + + + + + diff --git a/src/web/src/main.js b/src/web/src/main.js index 825e4816847..9496151fbb0 100644 --- a/src/web/src/main.js +++ b/src/web/src/main.js @@ -1,7 +1,9 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2026, The OpenROAD Authors -import { GoldenLayout, LayoutConfig } from 'https://esm.sh/golden-layout@2.6.0'; +// Resolved by the import map in index.html to the vendored copy under +// third-party/, which the OpenROAD binary serves (issue #11065). +import { GoldenLayout, LayoutConfig } from 'golden-layout'; import { latLngToDbu, dbuToLatLng, dbuRectToBounds } from './coordinates.js'; import { WebSocketManager } from './websocket-manager.js'; import { diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp index da059f9ed99..e36cd27fb08 100644 --- a/src/web/src/web.cpp +++ b/src/web/src/web.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,7 @@ #include "boost/json/value.hpp" #include "clock_tree_report.h" #include "color.h" +#include "css_inliner.h" #include "gui/heatMap.h" #include "hierarchy_report.h" #include "odb/db.h" @@ -49,6 +51,7 @@ #include "odb/dbChipCallBackObj.h" #include "request_dispatcher.h" #include "request_handler.h" +#include "sta/StringUtil.hh" #include "tcl.h" #include "tile_generator.h" #include "timing_report.h" @@ -1374,25 +1377,169 @@ WebServer::~WebServer() extern const std::string_view kReportCSS; extern const std::string_view kReportJS; -static std::string base64Encode(const std::vector& data) +static std::string base64Encode(const std::string_view data) { static const char kChars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const size_t size = data.size(); + const auto byte = [data](const size_t i) { + return static_cast(static_cast(data[i])); + }; std::string result; - result.reserve((data.size() + 2) / 3 * 4); - for (size_t i = 0; i < data.size(); i += 3) { - const unsigned b0 = data[i]; - const unsigned b1 = (i + 1 < data.size()) ? data[i + 1] : 0; - const unsigned b2 = (i + 2 < data.size()) ? data[i + 2] : 0; + result.reserve((size + 2) / 3 * 4); + for (size_t i = 0; i < size; i += 3) { + const unsigned b0 = byte(i); + const unsigned b1 = (i + 1 < size) ? byte(i + 1) : 0; + const unsigned b2 = (i + 2 < size) ? byte(i + 2) : 0; result += kChars[b0 >> 2]; result += kChars[((b0 & 3) << 4) | (b1 >> 4)]; - result - += (i + 1 < data.size()) ? kChars[((b1 & 0xF) << 2) | (b2 >> 6)] : '='; - result += (i + 2 < data.size()) ? kChars[b2 & 0x3F] : '='; + result += (i + 1 < size) ? kChars[((b1 & 0xF) << 2) | (b2 >> 6)] : '='; + result += (i + 2 < size) ? kChars[b2 & 0x3F] : '='; + } + return result; +} + +static std::string base64Encode(const std::vector& data) +{ + return base64Encode(std::string_view( + reinterpret_cast(data.data()), data.size())); +} + +// ── Inlining the vendored libraries into the saved report ── +// The report is one file, opened with no server behind it, so every asset the +// viewer loads is inlined here as a data: URI (issue #11065). + +std::string resolveAssetPath(const std::string_view base_dir, + const std::string_view reference) +{ + // generic_string(), not string(): the result is a key into the asset table, + // whose paths are always '/'-separated whatever the host uses. + return (std::filesystem::path(base_dir) / std::filesystem::path(reference)) + .lexically_normal() + .generic_string(); +} + +const EmbeddedAsset* ReportAssets::find(const std::string_view path) +{ + const EmbeddedAsset* asset = findEmbeddedAsset(path); + if (!asset) { + // A miss is a binary built with the wrong asset list, not bad input. + logger_->warn(utl::WEB, 77, "Missing embedded asset {}.", path); + missing_ = true; + } + return asset; +} + +// data: URI for an embedded asset, for use as a src or href in the report. +static std::string assetDataUri(const std::string_view path, + ReportAssets& assets) +{ + const EmbeddedAsset* asset = assets.find(path); + if (!asset) { + return ""; + } + return std::string("data:") + asset->content_type + ";base64," + + base64Encode(asset->content()); +} + +size_t findUrlToken(const std::string_view css, const size_t from) +{ + // Driven off the '(' so the scan is a memchr and not a byte loop. + for (size_t paren = from; + (paren = css.find('(', paren)) != std::string_view::npos; + ++paren) { + if (paren < 3) { + continue; + } + const size_t at = paren - 3; + if (at < from || !sta::stringBeginEqual(css.substr(at), "url")) { + continue; + } + const char before = at > 0 ? css[at - 1] : ' '; + if (std::isalnum(static_cast(before)) == 0 && before != '_' + && before != '-') { + return at; + } + } + return std::string_view::npos; +} + +std::string inlineStylesheetUrls(const std::string_view css, + const std::string_view base_dir, + ReportAssets& assets) +{ + std::string result; + size_t pos = 0; + while (true) { + const size_t open = findUrlToken(css, pos); + if (open == std::string_view::npos) { + break; + } + // A quoted reference may hold a parenthesis, so its closing quote bounds + // the token; an unquoted one ends at the ')'. + const size_t first = css.find_first_not_of(" \t\r\n", open + 4); + if (first == std::string_view::npos) { + break; + } + size_t close = std::string_view::npos; + std::string_view reference; + if (css[first] == '"' || css[first] == '\'') { + const size_t quote = css.find(css[first], first + 1); + if (quote == std::string_view::npos) { + break; + } + close = css.find(')', quote + 1); + reference = css.substr(first + 1, quote - first - 1); + } else { + close = css.find(')', first); + if (close == std::string_view::npos) { + break; + } + reference = css.substr(first, close - first); + while (!reference.empty() + && std::isspace(static_cast(reference.back())) + != 0) { + reference.remove_suffix(1); + } + } + if (close == std::string_view::npos) { + break; + } + + // Fragment-only references (url(#default#VML)) and anything already + // inlined are left alone. + if (reference.empty() || reference.front() == '#' + || reference.starts_with("data:")) { + result += css.substr(pos, close + 1 - pos); + pos = close + 1; + continue; + } + + result += css.substr(pos, open - pos); + result += "url(\""; + result += assetDataUri(resolveAssetPath(base_dir, reference), assets); + result += "\")"; + pos = close + 1; } + result += css.substr(pos); return result; } +// data: URI for an embedded stylesheet, with its own references inlined +// against the directory it is served from. +static std::string stylesheetDataUri(const std::string_view path, + ReportAssets& assets) +{ + const EmbeddedAsset* asset = assets.find(path); + if (!asset) { + return ""; + } + const std::string_view base_dir = path.substr(0, path.rfind('/') + 1); + return "data:text/css;base64," + + base64Encode( + inlineStylesheetUrls(asset->content(), base_dir, assets)); +} + void WebServer::saveReport(const std::string& filename, const int max_setup, const int max_hold) @@ -1414,6 +1561,7 @@ void WebServer::saveReport(const std::string& filename, logger_->error(utl::WEB, 31, "Cannot open file: {}", filename); return; } + ReportAssets assets(logger_); // ── Serialize JSON cache responses ── @@ -1530,20 +1678,44 @@ void WebServer::saveReport(const std::string& filename, // ── Write the HTML ── - // HTML head — same CDN deps as index.html. + // HTML head — leaflet, golden-layout and three, inlined as data: URIs so the + // file opens with no server and no network. elk and netlistsvg are left out: + // they are 2.8 MB for a schematic panel that needs the server anyway, and the + // widget already stands down when it does not find them. The stylesheets + // stay elements rather than @@ -1625,9 +1797,20 @@ window.__STATIC_CACHE__ = { } }; + @@ -1636,6 +1819,20 @@ import * as THREE from 'https://esm.sh/three@0.160.0'; )"; out.close(); + + if (assets.missing()) { + // The warnings above name what was missed; no one is told this was saved. + // The error below is the one that has to come out, so a removal that fails + // must not throw over it. + std::error_code remove_error; + std::filesystem::remove(filename, remove_error); + logger_->error(utl::WEB, + 78, + "Not saving {}: the binary was built with an incomplete " + "asset list.", + filename); + return; + } logger_->info(utl::WEB, 32, "Saved timing report to {}", filename); } diff --git a/src/web/src/web_assets.h b/src/web/src/web_assets.h index f758b796d0f..cfd28f2512c 100644 --- a/src/web/src/web_assets.h +++ b/src/web/src/web_assets.h @@ -17,8 +17,19 @@ struct EmbeddedAsset std::string_view content() const { return {data, size}; } }; +struct EmbeddedAssetEntry +{ + std::string_view path; + EmbeddedAsset asset; +}; + // Returns the embedded asset for the given URL path (e.g. "/index.html"), // or nullptr if not found. const EmbeddedAsset* findEmbeddedAsset(std::string_view path); +// Iteration over the whole asset table, for the test that holds every asset to +// the rule that none of them loads anything remote. +size_t embeddedAssetCount(); +const EmbeddedAssetEntry& embeddedAssetAt(size_t index); + } // namespace web diff --git a/src/web/test/BUILD b/src/web/test/BUILD index f4ae069badc..2c9b8d4335b 100644 --- a/src/web/test/BUILD +++ b/src/web/test/BUILD @@ -4,6 +4,7 @@ load("@npm//:defs.bzl", "npm_link_all_packages") # SPDX-License-Identifier: BSD-3-Clause # Copyright (c) 2022-2026, The OpenROAD Authors load("@rules_cc//cc:cc_test.bzl", "cc_test") +load("@rules_python//python:defs.bzl", "py_test") load("//test:regression.bzl", "regression_test") package(features = ["layering_check"]) @@ -36,6 +37,26 @@ filegroup( data = [":test_resources"], ) for test_name in TESTS] +# The report bundler strips module syntax with regexes; this pins the shapes. +py_test( + name = "embed_report_assets_test", + srcs = ["embed_report_assets_test.py"], + data = ["//src/web:src/embed_report_assets.py"], + legacy_create_init = 0, +) + +# The asset lists live once per build system; this is what keeps them equal. +py_test( + name = "asset_list_parity_test", + srcs = ["asset_list_parity.py"], + data = [ + "//src/web:BUILD", + "//src/web:CMakeLists.txt", + ], + legacy_create_init = 0, + main = "asset_list_parity.py", +) + JS_FILES = ["//src/web:js_files"] DOM_TEST_DATA = [ @@ -46,10 +67,35 @@ DOM_TEST_DATA = [ test_suite( name = "cpp_tests", tests = [ + ":css_inliner_test", ":gif_test", ":request_handler_test", ":save_display_controls_test", ":tile_generator_test", + ":web_assets_test", + ], +) + +cc_test( + name = "css_inliner_test", + srcs = ["cpp/TestCssInliner.cpp"], + deps = [ + "//src/gui:gui_stub", + "//src/utl", + "//src/web", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "web_assets_test", + srcs = ["cpp/TestWebAssets.cpp"], + deps = [ + "//src/gui:gui_stub", + "//src/web", + "@googletest//:gtest", + "@googletest//:gtest_main", ], ) @@ -480,6 +526,7 @@ cc_test( "//src/gui:gui_stub", "//src/odb/src/db", "//src/tst:nangate45_fixture", + "//src/utl", "//src/web", "@boost.json", "@googletest//:gtest", diff --git a/src/web/test/CMakeLists.txt b/src/web/test/CMakeLists.txt index f2a3b850a9a..48338197be7 100644 --- a/src/web/test/CMakeLists.txt +++ b/src/web/test/CMakeLists.txt @@ -7,4 +7,23 @@ or_integration_tests( cpp_tests ) +# The vendored libraries under src/web/third-party are generated files; this +# catches one being edited in place instead of through the script (issue #11065). +add_test( + NAME web_vendor_lock + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/../third-party/update_vendor.py --check +) + +# The report bundler strips module syntax with regexes; this pins the shapes. +add_test( + NAME web_embed_report_assets + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/embed_report_assets_test.py +) + +# The asset lists exist once per build system; this keeps them equal. +add_test( + NAME web_asset_list_parity + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/asset_list_parity.py +) + add_subdirectory(cpp) diff --git a/src/web/test/asset_list_parity.py b/src/web/test/asset_list_parity.py new file mode 100755 index 00000000000..229598d9cdd --- /dev/null +++ b/src/web/test/asset_list_parity.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors +# +# The web assets are listed once for Bazel and once for CMake, and nothing else +# ties the two together: a file forgotten in one 404s under that build system, +# and the report's list is ordered, since its files share one scope. Both have +# drifted once already. This test is the tie. + +import os +import re +import sys + +WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") + + +def read(name): + with open(os.path.join(WEB_DIR, name), encoding="utf-8") as f: + return f.read() + + +def bazel_list(build, name): + """The paths in a `name = [ ... ]` list assignment.""" + body = re.search(rf"^{name} = \[(.*?)^\]", build, re.M | re.S).group(1) + return re.findall(r'"([^"]+)"', body) + + +def cmake_list(cmakelists, name): + """The paths in a `set(NAME ...)` block, comments dropped.""" + body = re.search(rf"^set\({name}\n(.*?)^\)", cmakelists, re.M | re.S).group(1) + return [ + line.strip() + for line in body.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + +def cmake_command_args(cmakelists, after): + """The src/ paths passed on a command line, in order.""" + body = cmakelists[cmakelists.index(after) :] + body = body[: body.index("DEPENDS")] + return re.findall(r"\$\{CMAKE_CURRENT_SOURCE_DIR\}/(\S+)", body) + + +def main(): + build = read("BUILD") + cmakelists = read("CMakeLists.txt") + + problems = [] + + # The embedded assets: same set, since the served path is derived from the + # file path the same way on both sides. + bazel_assets = set(bazel_list(build, "_WEB_ASSET_FILES")) + cmake_assets = set(cmake_list(cmakelists, "WEB_ASSET_FILES")) + for missing in sorted(bazel_assets - cmake_assets): + problems.append(f"embedded in BUILD but not in CMakeLists.txt: {missing}") + for missing in sorted(cmake_assets - bazel_assets): + problems.append(f"embedded in CMakeLists.txt but not in BUILD: {missing}") + + # The report JS: same order, not just the same set. + bazel_js = bazel_list(build, "_REPORT_JS_FILES") + cmake_js = [ + path + for path in cmake_command_args(cmakelists, "embed_report_assets.py") + if path != "src/style.css" and path != "src/embed_report_assets.py" + ] + if bazel_js != cmake_js: + problems.append( + "the report JS lists differ (order matters -- the files share one " + f"scope):\n BUILD: {bazel_js}\n CMake: {cmake_js}" + ) + + if problems: + print("\n".join(problems), file=sys.stderr) + return 1 + + print( + f"{len(bazel_assets)} embedded assets and {len(bazel_js)} report JS files " + "match between BUILD and CMakeLists.txt" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/web/test/cpp/CMakeLists.txt b/src/web/test/cpp/CMakeLists.txt index 7d31c577ef6..801ca457ac7 100644 --- a/src/web/test/cpp/CMakeLists.txt +++ b/src/web/test/cpp/CMakeLists.txt @@ -210,6 +210,33 @@ gtest_discover_tests(TestGif WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. ) +add_executable(TestWebAssets TestWebAssets.cpp) + +target_link_libraries(TestWebAssets ${TEST_LIBS}) + +target_include_directories(TestWebAssets + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../src +) + +gtest_discover_tests(TestWebAssets + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. +) + +add_executable(TestCssInliner TestCssInliner.cpp ${GUI_STUB_SRCS}) + +target_link_libraries(TestCssInliner ${TEST_LIBS}) + +target_include_directories(TestCssInliner + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../src + ${GUI_STUB_INCLUDES} +) + +gtest_discover_tests(TestCssInliner + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. +) + add_dependencies(build_and_test TestTileGenerator TestRequestHandler @@ -224,4 +251,6 @@ add_dependencies(build_and_test TestLabels TestSaveDisplayControls TestGif + TestWebAssets + TestCssInliner ) diff --git a/src/web/test/cpp/TestCssInliner.cpp b/src/web/test/cpp/TestCssInliner.cpp new file mode 100644 index 00000000000..060cfa44e6d --- /dev/null +++ b/src/web/test/cpp/TestCssInliner.cpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors +// +// The saved report rewrites the url() references in the stylesheets it inlines +// (issue #11065). One the scanner misses ships a broken report; one it invents +// refuses a good one. Both edges are pinned here, against the real icons. + +#include +#include +#include + +#include "css_inliner.h" +#include "gtest/gtest.h" +#include "utl/Logger.h" + +namespace web { +namespace { + +constexpr std::string_view kLeafletDir = "/third-party/leaflet/"; +constexpr std::string_view kLayers = "images/layers.png"; + +bool contains(const std::string& haystack, const std::string_view needle) +{ + return haystack.find(needle) != std::string::npos; +} + +// ─── findUrlToken ─────────────────────────────────────────────────────────── + +TEST(CssInliner, UrlTokenIsCaseInsensitive) +{ + // A dependency bump that emits URL( must not slip a reference through. + for (const char* css : {"a{background:url(x.png)}", + "a{background:URL(x.png)}", + "a{background:Url(x.png)}", + "a{background:uRl(x.png)}"}) { + EXPECT_EQ(findUrlToken(css, 0), 13u) << css; + } +} + +TEST(CssInliner, UrlTokenHasToBeAToken) +{ + // The tail of an identifier is not a reference. + EXPECT_EQ(findUrlToken("a{content:myurl(x.png)}", 0), std::string_view::npos); + EXPECT_EQ(findUrlToken("a{content:foo-url(x.png)}", 0), + std::string_view::npos); + EXPECT_EQ(findUrlToken("a{content:x_url(x.png)}", 0), std::string_view::npos); + + // A boundary is anything that cannot continue an identifier. + EXPECT_EQ(findUrlToken("url(x.png)", 0), 0u); + EXPECT_EQ(findUrlToken("a{background:url(x.png)}", 0), 13u); + EXPECT_EQ(findUrlToken("@import url(x.css);", 0), 8u); +} + +TEST(CssInliner, UrlTokenStartsAtFrom) +{ + const std::string_view css = "a{b:url(1.png);c:url(2.png)}"; + const size_t first = findUrlToken(css, 0); + ASSERT_EQ(first, 4u); + EXPECT_EQ(findUrlToken(css, first + 1), 17u); + EXPECT_EQ(findUrlToken(css, 18), std::string_view::npos); +} + +// ─── resolveAssetPath ─────────────────────────────────────────────────────── + +TEST(CssInliner, ResolvesAgainstTheServedDirectory) +{ + EXPECT_EQ(resolveAssetPath(kLeafletDir, kLayers), + "/third-party/leaflet/images/layers.png"); + EXPECT_EQ(resolveAssetPath("/third-party/golden-layout/css/themes/", + "../../img/lm_close_white.png"), + "/third-party/golden-layout/img/lm_close_white.png"); + EXPECT_EQ(resolveAssetPath(kLeafletDir, "./images/./layers.png"), + "/third-party/leaflet/images/layers.png"); + // A reference that starts at the root replaces the directory. + EXPECT_EQ(resolveAssetPath(kLeafletDir, "/three/x.js"), "/three/x.js"); +} + +// ─── inlineStylesheetUrls ─────────────────────────────────────────────────── + +TEST(CssInliner, InlinesEveryReferenceForm) +{ + utl::Logger logger; + const std::string inlined = "url(\"data:image/png;base64,"; + + for (const char* css : {"a{background:url(images/layers.png)}", + "a{background:url( images/layers.png )}", + "a{background:url('images/layers.png')}", + "a{background:url(\"images/layers.png\")}", + "a{background:URL(images/layers.png) no-repeat}", + "a{background:url(/third-party/leaflet/" + "images/layers.png)}"}) { + ReportAssets assets(&logger); + const std::string out = inlineStylesheetUrls(css, kLeafletDir, assets); + EXPECT_TRUE(contains(out, inlined)) << css << " -> " << out; + EXPECT_FALSE(assets.missing()) << css; + } +} + +TEST(CssInliner, LeavesAloneWhatIsNotAReference) +{ + utl::Logger logger; + ReportAssets assets(&logger); + + // leaflet.css really carries the first of these. + for (const char* css : {"a{behavior:url(#default#VML)}", + "a{background:url(\"data:image/png;base64,AA\")}", + "a{content:myurl(images/layers.png)}"}) { + EXPECT_EQ(inlineStylesheetUrls(css, kLeafletDir, assets), css) << css; + } + EXPECT_FALSE(assets.missing()); +} + +TEST(CssInliner, AQuotedReferenceMayHoldAParenthesis) +{ + utl::Logger logger; + ReportAssets assets(&logger); + + // The token ends at the quote, not the first ')', which would cut the + // stylesheet in half. No such icon exists, so it resolves to a miss. + const std::string out = inlineStylesheetUrls( + "a{background:url(\"a(b).png\")}!", kLeafletDir, assets); + EXPECT_EQ(out, "a{background:url(\"\")}!"); + EXPECT_TRUE(assets.missing()); +} + +TEST(CssInliner, MissingReferenceIsReported) +{ + utl::Logger logger; + ReportAssets assets(&logger); + + inlineStylesheetUrls("a{background:url(nope.png)}", kLeafletDir, assets); + EXPECT_TRUE(assets.missing()); +} + +} // namespace +} // namespace web diff --git a/src/web/test/cpp/TestSaveReport.cpp b/src/web/test/cpp/TestSaveReport.cpp index bb3c615fca4..e5a5b2aebe3 100644 --- a/src/web/test/cpp/TestSaveReport.cpp +++ b/src/web/test/cpp/TestSaveReport.cpp @@ -3,26 +3,45 @@ #include +#include #include #include #include #include #include +#include #include #include #include "boost/json/serialize.hpp" +#include "css_inliner.h" #include "gtest/gtest.h" #include "odb/db.h" #include "odb/dbTypes.h" #include "tile_generator.h" #include "timing_report.h" #include "tst/nangate45_fixture.h" +#include "utl/decode.h" #include "web/web.h" namespace web { namespace { +// Does the line start with this keyword, as a keyword? "importantly" and +// "exports" are identifiers, not module syntax. +bool isKeyword(const std::string_view line, const std::string_view keyword) +{ + if (!line.starts_with(keyword)) { + return false; + } + if (line.size() == keyword.size()) { + return true; + } + const char next = line[keyword.size()]; + return std::isalnum(static_cast(next)) == 0 && next != '_' + && next != '$'; +} + // ─── Fixture ──────────────────────────────────────────────────────────────── class SaveReportTest : public tst::Nangate45Fixture @@ -134,8 +153,12 @@ TEST_F(SaveReportTest, ContainsRequiredHTMLElements) 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")); + // The stylesheets are inlined, so their file names are gone; what has to + // survive is the element, which theme.js toggles by id. + EXPECT_TRUE( + contains(html, ", with their import/export +// statements stripped: one that survives costs every widget in the report. +TEST_F(SaveReportTest, InlinedScriptHasNoModuleSyntaxLeft) { - const std::string path = tempHtml("gl_cdn"); + const std::string path = tempHtml("module_syntax"); generateReport(path); const std::string html = readFile(path); - // GoldenLayout loaded via ES module import from CDN. + const std::string opening = "", begin); + ASSERT_NE(end, std::string::npos); + const std::string module + = html.substr(begin + opening.size(), end - begin - opening.size()); + + // The generator marks every source, so the first marker is the boundary: + // before it the header's imports, after it code with no module syntax. + const size_t body = module.find("// ── "); + ASSERT_NE(body, std::string::npos); + + int header_imports = 0; + for (size_t at = 0; at < module.size();) { + const size_t eol = std::min(module.find('\n', at), module.size()); + std::string_view line(module.data() + at, eol - at); + const bool in_header = at < body; + at = eol + 1; + + const size_t indent = line.find_first_not_of(" \t"); + if (indent == std::string_view::npos) { + continue; + } + line.remove_prefix(indent); + if (!isKeyword(line, "import") && !isKeyword(line, "export")) { + continue; + } + if (in_header && isKeyword(line, "import")) { + ++header_imports; + continue; + } + ADD_FAILURE() << "leftover module syntax: " << line; + } + // GoldenLayout and THREE, however they are resolved. + EXPECT_EQ(header_imports, 2); +} + +// The report opens from file:// with nothing behind it, so nothing in it may +// point at a remote host (issue #11065). +TEST_F(SaveReportTest, IsSelfContained) +{ + const std::string path = tempHtml("self_contained"); + generateReport(path); + const std::string html = readFile(path); + + // No attribute, url() or specifier may name a remote origin. Bare "http://" + // is left alone: the widgets carry XML namespaces, which are identifiers. + for (const char* fetch : {"src=\"http", + "src='http", + "href=\"http", + "href='http", + "url(http", + "url(\"http", + "url('http", + "from 'http", + "from \"http"}) { + EXPECT_FALSE(contains(html, fetch)) << fetch; + } + // leaflet as a classic script, three and golden-layout as ES modules the + // import map redirects to their inlined copies. + EXPECT_TRUE( + contains(html, " - + +