Mercurial
changeset 223:0e7b9464248d hg-web
[hg-web] Add browser route regression coverage
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 10:07:40 -0700 |
| parents | a8d6435dc021 |
| children | 3007ef5fc0ed |
| files | MODULE.bazel MODULE.bazel.lock hg-web/BUILD hg-web/README.md hg-web/e2e/BUILD hg-web/e2e/app_e2e_test.js hg-web/e2e/package-lock.json hg-web/e2e/package.json hg-web/e2e/pnpm-lock.yaml hg-web/main.c hg-web/src/components/app.tsx hg-web/src/components/directory-browser.tsx hg-web/src/index.css seobeo/s_network.c seobeo/s_web.c seobeo/seobeo.h seobeo/tests/BUILD seobeo/tests/seobeo_response_test.c seobeo/tests/seobeo_sigpipe_test.c |
| diffstat | 19 files changed, 792 insertions(+), 47 deletions(-) [+] |
line wrap: on
line diff
--- a/MODULE.bazel Sun Aug 02 09:07:36 2026 -0700 +++ b/MODULE.bazel Sun Aug 02 10:07:40 2026 -0700 @@ -2,6 +2,8 @@ bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "rules_shell", version = "0.6.1") +bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True) +bazel_dep(name = "rules_nodejs", version = "6.7.3", dev_dependency = True) bazel_dep(name = "openssl", version = "3.3.1.bcr.7") bazel_dep(name = "rules_foreign_cc", version = "0.14.0") bazel_dep(name = "buildifier_prebuilt", version = "7.1.2", dev_dependency = True) @@ -13,6 +15,36 @@ ) http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file") +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "playwright_chromium_linux", + urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"], + sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5", + strip_prefix = "chrome-linux", + build_file_content = """ +exports_files(["chrome"]) + +filegroup( + name = "chromium", + srcs = glob(["**"], exclude = ["BUILD.bazel"]), + visibility = ["//visibility:public"], +) +""", +) + +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain(node_version = "20.18.0") +use_repo(node, "nodejs") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "hg_web_npm", + data = ["//hg-web/e2e:package.json"], + npm_package_lock = "//hg-web/e2e:package-lock.json", + pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml", +) +use_repo(npm, "hg_web_npm") # Bun http_file(
--- a/MODULE.bazel.lock Sun Aug 02 09:07:36 2026 -0700 +++ b/MODULE.bazel.lock Sun Aug 02 10:07:40 2026 -0700 @@ -1,5 +1,5 @@ { - "lockFileVersion": 26, + "lockFileVersion": 28, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -43,8 +43,9 @@ "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d", - "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", @@ -65,8 +66,8 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/MODULE.bazel": "d18b017dddf219626ea5b04028ff0db2397655fbdaa9d9258a6443ddafb303ab", "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/source.json": "5fb3a2433f6508f4b23934eaa2986d604ae65aff95d7476d1273bf5cd434eedc", - "https://bcr.bazel.build/modules/buildozer/8.2.1/MODULE.bazel": "61e9433c574c2bd9519cad7fa66b9c1d2b8e8d5f3ae5d6528a2c2d26e68d874d", - "https://bcr.bazel.build/modules/buildozer/8.2.1/source.json": "7c33f6a26ee0216f85544b4bca5e9044579e0219b6898dd653f5fb449cf2e484", + "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f", + "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", @@ -135,7 +136,8 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/MODULE.bazel": "56fb9a239503bab4183d06ba6cabb01cd73aae296ab499085b9193624a8a66e2", @@ -153,8 +155,8 @@ "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", - "https://bcr.bazel.build/modules/rules_java/9.0.3/MODULE.bazel": "1f98ed015f7e744a745e0df6e898a7c5e83562d6b759dfd475c76456dda5ccea", - "https://bcr.bazel.build/modules/rules_java/9.0.3/source.json": "b038c0c07e12e658135bbc32cc1a2ded6e33785105c9d41958014c592de4593e", + "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d", + "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", @@ -228,9 +230,105 @@ }, "selectedYankedVersions": {}, "moduleExtensions": { + "@@aspect_rules_js+//npm:extensions.bzl%pnpm": { + "general": { + "bzlTransitiveDigest": "A3wObJgLD5IcmyBKFyx9nfycX4VosrUq4UtGAHdyZ8M=", + "usagesDigest": "UCPGCdTkoflpzf0ShZ9hsk2zN6bbQlRvpRRZJnniDDc=", + "recordedInputs": [ + "REPO_MAPPING:aspect_bazel_lib+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:aspect_bazel_lib+,tar.bzl tar.bzl+", + "REPO_MAPPING:aspect_rules_js+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+", + "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools", + "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", + "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", + "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:tar.bzl+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+" + ], + "generatedRepoSpecs": { + "pnpm": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "root_package": "", + "link_workspace": "", + "link_packages": {}, + "integrity": "sha512-SZQ0ydj90aJ5Tr9FUrOyXApjOrzuW7Fee13pDzL0e1E6ypjNXP0AHDHw20VLw4BO3M1XhQHkyik6aBYWa72fgQ==", + "url": "", + "commit": "", + "patch_args": [ + "-p0" + ], + "patches": [], + "custom_postinstall": "", + "npm_auth": "", + "npm_auth_basic": "", + "npm_auth_username": "", + "npm_auth_password": "", + "lifecycle_hooks": [], + "extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])", + "extract_full_archive": true, + "exclude_package_contents": [] + } + }, + "pnpm__links": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "dev": false, + "root_package": "", + "link_packages": {}, + "deps": {}, + "transitive_closure": {}, + "lifecycle_build_target": false, + "lifecycle_hooks_env": [], + "lifecycle_hooks_execution_requirements": [ + "no-sandbox" + ], + "lifecycle_hooks_use_default_shell_env": false, + "bins": {}, + "package_visibility": [ + "//visibility:public" + ], + "exclude_package_contents": [] + } + } + } + } + }, + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "general": { + "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=", + "usagesDigest": "TQSuPERI87Z4Alo1eOWeUR3NGo5f3txcCOd4tpsGXmw=", + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" + ], + "generatedRepoSpecs": { + "aspect_tools_telemetry_report": { + "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", + "attributes": { + "deps": { + "aspect_rules_js": "2.9.2", + "aspect_tools_telemetry": "0.3.3" + } + } + } + } + } + }, "@@buildifier_prebuilt+//:defs.bzl%buildifier_prebuilt_deps_extension": { "general": { - "bzlTransitiveDigest": "27R0vlu7+T5uJVRkrJWwjP+5K4Pi5H1nJbI3JBLJyKI=", + "bzlTransitiveDigest": "HljGbKDagP11Zj2xu7uXH4csvPiytXXlNpFDe1PeuTs=", "usagesDigest": "BWWW2nG29mUymMGpRgfP+nxndKWaf1fCZZ33EDPaVIc=", "recordedInputs": [ "REPO_MAPPING:buildifier_prebuilt+,bazel_skylib bazel_skylib+", @@ -374,7 +472,7 @@ }, "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": { "general": { - "bzlTransitiveDigest": "QDGe6PSXCStGFYJd3Yj2kvRMiXLid2u8mNHBRjJoOEw=", + "bzlTransitiveDigest": "+1O9FmrwgJwK/ywCKhwouNuu7NWC/hfkkzjuGp/1c9Q=", "usagesDigest": "f1E1WkCSGt50uwBJp/heJk2R/iAm39ixgGS93wpdywA=", "recordedInputs": [ "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", @@ -444,9 +542,31 @@ } } }, + "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { + "general": { + "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=", + "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", + "recordedInputs": [ + "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", + "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" + ], + "generatedRepoSpecs": { + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", + "strip_prefix": "pybind11-2.12.0", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.12.0.zip" + ] + } + } + } + } + }, "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": { "general": { - "bzlTransitiveDigest": "dWWiLQWKST2+NU4SkbVS2OhS6KgYdZ4jBI5MBsDEBt0=", + "bzlTransitiveDigest": "G54TxUUn6vxT4n5eIpNwbycPtXtiR354MSgHTcQAnHI=", "usagesDigest": "Eyh4mAOi6L+Nn/lY/wQBJclQrmBnWdQM+B4lZeq6azA=", "recordedInputs": [ "REPO_MAPPING:rules_foreign_cc+,bazel_tools bazel_tools", @@ -819,7 +939,7 @@ }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "ABI1D/sbS1ovwaW/kHDoj8nnXjQ0oKU9fzmzEG4iT8o=", + "bzlTransitiveDigest": "+Kp6j204mBZ3mxlIDDR0gBoP45BZ4jYRhRAcB8sU0qc=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", "recordedInputs": [ "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" @@ -877,7 +997,7 @@ "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=", - "usagesDigest": "1PdljUSmnJF0C3nTMWMdndAlDqMjOzQHlOp2TAszTxk=", + "usagesDigest": "aOq2mNhv/K35rllf0LkQnk575K/CZNf1RTfrNe1kDkY=", "recordedInputs": [], "generatedRepoSpecs": { "nodejs_linux_amd64": { @@ -1007,7 +1127,7 @@ }, "@@rules_python+//python/extensions:config.bzl%config": { "general": { - "bzlTransitiveDigest": "2hLgIvNVTLgxus0ZuXtleBe70intCfo0cHs8qvt6cdM=", + "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=", "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", @@ -1275,5 +1395,6 @@ } } }, - "facts": {} + "facts": {}, + "factsVersions": {} }
--- a/hg-web/BUILD Sun Aug 02 09:07:36 2026 -0700 +++ b/hg-web/BUILD Sun Aug 02 10:07:40 2026 -0700 @@ -59,6 +59,7 @@ srcs = ["main.c"], deps = ["//seobeo:seobeo"], data = [":all_assets"], + visibility = ["//hg-web:__subpackages__"], ) cc_binary( @@ -77,6 +78,11 @@ ], ) +test_suite( + name = "tests", + tests = ["//hg-web/e2e:app_e2e_test"], +) + bundle( name = "hg_web_server_bundle", binary = ":hg_web_server",
--- a/hg-web/README.md Sun Aug 02 09:07:36 2026 -0700 +++ b/hg-web/README.md Sun Aug 02 10:07:40 2026 -0700 @@ -169,10 +169,18 @@ ```bash bazel build //hg-web:hg_web_server bazel build //hg-web:hg_web_server_bundle +bazel test //hg-web:tests bazel test //markdown_converter/tests:markdown_to_html_test bazel test //seobeo/tests:all ``` +`//hg-web:tests` creates a temporary two-commit Mercurial repository and runs +the application in pinned Chromium through Playwright. It covers shell routes, +files and README rendering, graph and changeset navigation, browser history, +console and page errors, API validation, Mercurial wire protocol, Markdown +script escaping, static assets, and backend outages. The test invokes the +workspace Mercurial CLI with an isolated configuration and home directory. + Build and run the complete local stack with one Bazel command: ```bash
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/BUILD Sun Aug 02 10:07:40 2026 -0700 @@ -0,0 +1,31 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") +load("@hg_web_npm//:defs.bzl", "npm_link_all_packages") + +exports_files([ + "package-lock.json", + "package.json", + "pnpm-lock.yaml", +]) + +npm_link_all_packages(name = "node_modules") + +js_test( + name = "app_e2e_test", + entry_point = "app_e2e_test.js", + data = [ + ":node_modules/playwright-core", + "//hg-web:hg_web_server", + "@playwright_chromium_linux//:chromium", + "@playwright_chromium_linux//:chrome", + ], + env = { + "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)", + }, + no_copy_to_bin = ["@playwright_chromium_linux//:chromium"], + size = "large", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + timeout = "long", +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/app_e2e_test.js Sun Aug 02 10:07:40 2026 -0700 @@ -0,0 +1,332 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { chromium } = require('playwright-core'); + +const BASE_URL = 'http://127.0.0.1:6970'; +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed\n${result.stdout || ''}${result.stderr || ''}`, + ); + } + return result.stdout.trim(); +} + +function mercurialEnvironment(home) { + return { + ...process.env, + HGPLAIN: '1', + HGRCPATH: '', + HOME: home, + }; +} + +function stopProcess(child) { + if (!child || child.exitCode !== null) return Promise.resolve(); + child.kill('SIGTERM'); + return new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForHttp(url, child, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server exited early with ${child.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}\n${logs.join('')}`); +} + +function createFixtureRepository(root) { + const options = { env: mercurialEnvironment(root) }; + run('hg', ['init', root], options); + fs.mkdirSync(path.join(root, 'docs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'README.md'), + '# Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n', + ); + fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n'); + fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n'); + run('hg', ['--repository', root, 'add'], options); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options); + const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + + fs.appendFileSync(path.join(root, 'README.md'), '\nSecond revision.\n'); + fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n'); + run('hg', ['add', 'new-file.txt'], { ...options, cwd: root }); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options); + const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + return { firstNode, tipNode }; +} + +async function assertJson(pathname, status = 200) { + const response = await fetch(`${BASE_URL}${pathname}`); + const body = await response.text(); + assert.equal(response.status, status, `${pathname} status: ${body}`); + return JSON.parse(body); +} + +async function assertPageHasNoBrowserErrors(browser, pathname, assertion) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(`pageerror: ${error.stack || error.message}`)); + page.on('console', message => { + if (message.type() === 'error') errors.push(`console: ${message.text()}`); + }); + page.on('requestfailed', request => { + errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); + }); + page.on('response', response => { + if (response.status() >= 400) { + errors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + const response = await page.goto(`${BASE_URL}${pathname}`, { waitUntil: 'networkidle' }); + assert.equal(response.status(), 200, `${pathname} document status`); + await assertion(page); + if (errors.length > 0) { + throw new Error(`${pathname} browser errors\n${errors.join('\n')}`); + } + await page.close(); +} + +async function main() { + assert.ok(RUNFILES, 'rules_js runfiles path is required'); + assert.ok(WORKSPACE, 'rules_js workspace name is required'); + + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-web-e2e-')); + const appLogs = []; + const hgLogs = []; + let hgServer; + let appServer; + let browser; + + try { + const { firstNode, tipNode } = createFixtureRepository(fixtureRoot); + const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); + const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server'); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + + assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`); + assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`); + + hgServer = spawn( + 'hg', + [ + '--repository', fixtureRoot, + 'serve', + '--address', '127.0.0.1', + '--port', '4444', + '--accesslog', '-', + '--errorlog', '-', + ], + { + env: mercurialEnvironment(fixtureRoot), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + hgServer.stdout.on('data', chunk => hgLogs.push(chunk.toString())); + hgServer.stderr.on('data', chunk => hgLogs.push(chunk.toString())); + + appServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + stdio: ['ignore', 'pipe', 'pipe'], + }); + appServer.stdout.on('data', chunk => appLogs.push(chunk.toString())); + appServer.stderr.on('data', chunk => appLogs.push(chunk.toString())); + + await waitForHttp(`${BASE_URL}/`, appServer, appLogs); + if (hgServer.exitCode !== null) { + throw new Error(`Mercurial server exited early with ${hgServer.exitCode}\n${hgLogs.join('')}`); + } + await waitForHttp(`${BASE_URL}/api/repo/list`, hgServer, hgLogs); + + for (const pathname of ['/', '/directory', '/directory?path=docs', '/graph', `/changeset/${tipNode}`]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} shell route`); + assert.match(await response.text(), /<title>Zenbu Repository<\/title>/); + } + for (const pathname of ['/page.js', '/index.css', '/base.css', '/pencil_lines.png', '/panda.png']) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} static asset`); + assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`); + } + + const rootList = await assertJson('/api/repo/list'); + assert.ok(rootList.directories.some(entry => entry.basename === 'docs')); + assert.ok(rootList.files.some(entry => entry.basename === 'README.md')); + + const nestedList = await assertJson('/api/repo/list?path=docs'); + assert.ok(nestedList.files.some(entry => entry.basename === 'README.md')); + + const fileResponse = await fetch(`${BASE_URL}/api/repo/file?path=src%2Fmain.c`); + assert.equal(fileResponse.status, 200); + assert.match(await fileResponse.text(), /int main/); + + const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`); + assert.equal(readmeResponse.status, 200); + assert.match(await readmeResponse.text(), /Nested README/); + assert.equal((await fetch(`${BASE_URL}/api/repo/readme?path=src`)).status, 204); + + const graph = await assertJson('/api/graph/tip?style=json'); + assert.equal(graph.node, tipNode); + assert.ok(graph.changesets.length >= 2); + + const changeset = await assertJson(`/api/changeset/${tipNode}`); + assert.equal(changeset.node, tipNode); + assert.equal(changeset.desc, 'Update fixture'); + assert.ok(changeset.files.some(entry => entry.file === 'new-file.txt' && entry.status === 'added')); + assert.ok(changeset.diff.length > 0); + + for (const pathname of [ + '/api/repo/list?path=..%2Fetc', + '/api/repo/file?path=..%2FREADME.md', + '/api/graph/not-a-node?style=json', + '/api/changeset/not-a-node', + ]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 400, `${pathname} rejects invalid input`); + } + assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404); + + const identify = run( + 'hg', + ['identify', `${BASE_URL}/repo`], + { env: mercurialEnvironment(fixtureRoot) }, + ); + assert.match(identify, new RegExp(`^${tipNode.slice(0, 12)}`)); + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + + await assertPageHasNoBrowserErrors(browser, '/', async page => { + await page.getByRole('heading', { name: 'Zenbu Repository' }).waitFor(); + await page.getByText('Recent Commits').waitFor(); + await page.getByText('Repository Files').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + await page.locator('.theme-toggle').click(); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory', async page => { + await page.getByText('Repository Files').waitFor(); + await page.getByRole('link', { name: 'README.md' }).first().click(); + await page.getByText('Fixture Repository').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + await page.keyboard.press('Escape'); + + let delayedReadmeRequested = false; + let markReadmeRequested; + const readmeRequested = new Promise(resolve => { + markReadmeRequested = resolve; + }); + await page.route(/\/api\/repo\/readme\?path=/, async route => { + delayedReadmeRequested = true; + markReadmeRequested(); + await new Promise(resolve => setTimeout(resolve, 500)); + await route.continue(); + }); + await page.getByRole('link', { name: 'docs' }).click(); + await Promise.race([ + readmeRequested, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out waiting for delayed README request')), 5000); + }), + ]); + await page.getByRole('link', { name: 'root' }).click(); + await page.getByText('Fixture Repository').waitFor(); + await page.waitForTimeout(600); + assert.equal(await page.getByText('Documentation').count(), 0); + assert.equal(delayedReadmeRequested, true); + + await page.getByRole('link', { name: 'src' }).click(); + await page.getByRole('link', { name: 'main.c' }).click(); + await page.getByText('int main(void)').waitFor(); + await page.keyboard.press('Escape'); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory?path=docs', async page => { + await page.getByText('Documentation').waitFor(); + await page.getByRole('link', { name: 'README.md' }).click(); + await page.getByText('Nested README.').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/graph', async page => { + await page.getByText('Commit Graph').waitFor(); + await page.waitForFunction(() => new URL(window.location.href).searchParams.has('tip')); + const graphUrl = page.url(); + const rows = page.locator('.graph-row'); + await rows.first().click(); + await page.waitForURL(/\/changeset\/[0-9a-f]+$/); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.waitForFunction(expected => window.location.href === expected, graphUrl); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, `/changeset/${tipNode}`, async page => { + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor(); + await page.getByText('added', { exact: true }).waitFor(); + await page.getByRole('region', { name: 'Changeset diff' }).waitFor(); + await page.getByRole('button', { name: firstNode.slice(0, 12) }).click(); + await page.waitForFunction( + expectedPath => window.location.pathname === expectedPath, + `/changeset/${firstNode}`, + ); + await page.getByRole('heading', { name: 'Initial fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/changeset/not-a-node', async page => { + await page.getByText('Recent Commits').waitFor(); + }); + + await stopProcess(hgServer); + const unavailable = await fetch(`${BASE_URL}/api/repo/list`); + assert.equal(unavailable.status, 502); + assert.equal((await fetch(`${BASE_URL}/`)).status, 200); + } finally { + if (browser) await browser.close(); + await stopProcess(appServer); + await stopProcess(hgServer); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package-lock.json Sun Aug 02 10:07:40 2026 -0700 @@ -0,0 +1,24 @@ +{ + "name": "e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright-core": "1.48.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package.json Sun Aug 02 10:07:40 2026 -0700 @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "playwright-core": "1.48.2" + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/pnpm-lock.yaml Sun Aug 02 10:07:40 2026 -0700 @@ -0,0 +1,18 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + playwright-core: + specifier: 1.48.2 + version: 1.48.2 + +packages: + + /[email protected]: + resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==} + engines: {node: '>=18'} + hasBin: true + dev: false
--- a/hg-web/main.c Sun Aug 02 09:07:36 2026 -0700 +++ b/hg-web/main.c Sun Aug 02 10:07:40 2026 -0700 @@ -344,10 +344,14 @@ if (length < 0 || (size_t)length >= sizeof(hg_path)) return text_response(arena, "400", "text/plain", "README path is too long"); - return forward_hg_response( - hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"), - "text/markdown", - arena); + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"); + if (hg_response && hg_response->status_code == HTTP_NOT_FOUND) + { + Seobeo_Client_Response_Destroy(hg_response); + return text_response(arena, "204", "text/markdown", ""); + } + return forward_hg_response(hg_response, "text/markdown", arena); } Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena)
--- a/hg-web/src/components/app.tsx Sun Aug 02 09:07:36 2026 -0700 +++ b/hg-web/src/components/app.tsx Sun Aug 02 10:07:40 2026 -0700 @@ -13,6 +13,7 @@ graphTip?: string; dirPath?: string; changesetId?: string; + returnDepth?: number; } type ChangesetDetail = { @@ -24,7 +25,10 @@ tags: string[]; user: string; parents: string[]; - files: string[]; + files: Array<{ + file: string; + status: string; + }>; diff: Array<{ blockno: number; lines: Array<{ t: string; n: number; l: string }>; @@ -106,6 +110,13 @@ } } +function isRouteState(value: unknown): value is RouteState { + if (!value || typeof value !== 'object' || !('page' in value)) return false; + return ['landing', 'graph', 'directory', 'changeset'].includes( + String((value as { page: unknown }).page), + ); +} + // Landing Page Component function LandingPage({ onNavigateToGraph, @@ -242,7 +253,7 @@ return ( <div> <div className="page-header"> - <button className="back-button" onClick={onBack}> + <button className="back-button" onClick={onBack} aria-label="Back"> ← Back </button> <span className="page-title">Commit Graph</span> @@ -320,8 +331,8 @@ return ( <div> <div className="page-header"> - <button className="back-button" onClick={onBack}> - ← Back to graph + <button className="back-button" onClick={onBack} aria-label="Back"> + ← Back </button> <span className="page-title">Changeset</span> </div> @@ -364,7 +375,14 @@ {changeset.files.length > 0 && ( <div className="changeset-files"> <strong>Files</strong> - {changeset.files.map(file => <code key={file}>{file}</code>)} + {changeset.files.map(file => ( + <code key={file.file}> + <span className={`changeset-file-status status-${file.status}`}> + {file.status} + </span> + {file.file} + </code> + ))} </div> )} @@ -409,7 +427,7 @@ return ( <div> <div className="page-header"> - <button className="back-button" onClick={onBack}> + <button className="back-button" onClick={onBack} aria-label="Back"> ← Back </button> <span className="page-title">Repository Files</span> @@ -430,8 +448,8 @@ // Handle browser back/forward useEffect(() => { - const handlePopState = () => { - setRoute(parseRoute()); + const handlePopState = (event: PopStateEvent) => { + setRoute(isRouteState(event.state) ? event.state : parseRoute()); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); @@ -456,8 +474,19 @@ }, [navigate]); const navigateToChangeset = useCallback((changesetId: string) => { - navigate({ page: 'changeset', changesetId }); - }, [navigate]); + const returnDepth = route.page === 'changeset' + ? (route.returnDepth ?? 0) + 1 + : 1; + navigate({ page: 'changeset', changesetId, returnDepth }); + }, [navigate, route.page, route.returnDepth]); + + const navigateBackFromChangeset = useCallback(() => { + if (route.returnDepth !== undefined && route.returnDepth > 0) { + window.history.go(-route.returnDepth); + return; + } + navigateToGraph(); + }, [navigateToGraph, route.returnDepth]); const handleDirectoryPathChange = useCallback((path: string) => { // Update URL without full navigation @@ -530,7 +559,7 @@ {route.page === 'changeset' && route.changesetId && ( <ChangesetPage changesetId={route.changesetId} - onBack={() => navigateToGraph()} + onBack={navigateBackFromChangeset} onOpenChangeset={navigateToChangeset} /> )}
--- a/hg-web/src/components/directory-browser.tsx Sun Aug 02 09:07:36 2026 -0700 +++ b/hg-web/src/components/directory-browser.tsx Sun Aug 02 10:07:40 2026 -0700 @@ -429,6 +429,7 @@ const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [viewingFile, setViewingFile] = useState<string | null>(null); + const requestGeneration = useRef(0); // Sync with initialPath prop useEffect(() => { @@ -436,8 +437,9 @@ }, [initialPath]); useEffect(() => { - fetchDirectory(currentPath); - fetchReadme(currentPath); + const generation = ++requestGeneration.current; + fetchDirectory(currentPath, generation); + fetchReadme(currentPath, generation); }, [currentPath]); const navigate = useCallback((path: string) => { @@ -445,7 +447,7 @@ onPathChange?.(path); }, [onPathChange]); - const fetchDirectory = async (path: string) => { + const fetchDirectory = async (path: string, generation: number) => { setLoading(true); setError(null); try { @@ -468,26 +470,30 @@ throw new Error(data.error); } - setContent({ - files: data?.files || [], - directories: data?.directories || [] - }); + if (generation === requestGeneration.current) { + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } } catch (err: any) { console.error('Error loading directory:', err); - setError(err.message); + if (generation === requestGeneration.current) setError(err.message); } finally { - setLoading(false); + if (generation === requestGeneration.current) setLoading(false); } }; - const fetchReadme = async (path: string) => { - setReadme(null); - const readmePath = path ? `${path}/README.md` : 'README.md'; + const fetchReadme = async (path: string, generation: number) => { + if (generation === requestGeneration.current) setReadme(null); try { - const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); + const url = path + ? `${API_BASE}/readme?path=${encodeURIComponent(path)}` + : `${API_BASE}/readme`; + const response = await fetch(url); if (response.ok) { const text = await response.text(); - setReadme(text); + if (generation === requestGeneration.current) setReadme(text || null); } } catch (err) { // Readme is optional
--- a/hg-web/src/index.css Sun Aug 02 09:07:36 2026 -0700 +++ b/hg-web/src/index.css Sun Aug 02 10:07:40 2026 -0700 @@ -451,9 +451,26 @@ } .changeset-files code { + display: inline-flex; + align-items: center; + gap: 6px; overflow-wrap: anywhere; } +.changeset-file-status { + color: var(--text-secondary); + font-size: 10px; + text-transform: uppercase; +} + +.changeset-file-status.status-added { + color: var(--success); +} + +.changeset-file-status.status-removed { + color: var(--danger); +} + .changeset-diff { margin-top: 24px; }
--- a/seobeo/s_network.c Sun Aug 02 09:07:36 2026 -0700 +++ b/seobeo/s_network.c Sun Aug 02 10:07:40 2026 -0700 @@ -1,8 +1,37 @@ #include "seobeo/seobeo.h" +static pthread_once_t g_sigpipe_once = PTHREAD_ONCE_INIT; + +static void Seobeo_Process_Ignore_Sigpipe(void) +{ + signal(SIGPIPE, SIG_IGN); +} + +static void Seobeo_Socket_Disable_Sigpipe(int socket_fd) +{ +#ifdef SO_NOSIGPIPE + int enabled = 1; + setsockopt(socket_fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); +#else + (void)socket_fd; +#endif +} + +static ssize_t Seobeo_Socket_Write( + int socket_fd, + const void *buffer, + size_t length) +{ +#ifdef MSG_NOSIGNAL + return send(socket_fd, buffer, length, MSG_NOSIGNAL); +#else + return send(socket_fd, buffer, length, 0); +#endif +} Seobeo_Handle *Seobeo_Stream_Handle_Server_Create(const char *host, const char* port) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; struct addrinfo hints, *server_infos, *free_server_info; int32 socket_fd, yes = 1; // Need this for setsockopt @@ -26,6 +55,7 @@ if((socket_fd = socket(free_server_info->ai_family, free_server_info->ai_socktype, free_server_info->ai_protocol)) == -1) { perror("socket"); continue; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1) { perror("setsockopt SO_REUSEADDR"); continue; } @@ -81,6 +111,7 @@ Seobeo_Handle *Seobeo_Stream_Handle_Client_Create(const char *host, const char* port, boolean use_tls) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; p_handle = malloc(sizeof(*p_handle)); @@ -98,6 +129,7 @@ if((socket_fd = socket(server_infos->ai_family, server_infos->ai_socktype, server_infos->ai_protocol)) == -1) { perror("socket"); return NULL; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (connect(socket_fd, server_infos->ai_addr, server_infos->ai_addrlen) != 0) { perror("connect"); return NULL; } @@ -154,6 +186,7 @@ Seobeo_Get_IP4_Or_IP6((struct sockaddr *)&addr), client_inet_addr, sizeof client_inet_addr); if (client_fd == -1) return NULL; + Seobeo_Socket_Disable_Sigpipe(client_fd); // Set non blocking... int flags = fcntl(client_fd, F_GETFL, 0); @@ -236,7 +269,7 @@ }else { Seobeo_Log(SEOBEO_DEBUG, "Flushing socket: %d\n", p_handle->socket); - ssize_t n = write( + ssize_t n = Seobeo_Socket_Write( p_handle->socket, p_handle->write_buffer + sent, total - sent @@ -275,9 +308,10 @@ } else { - n = write(p_handle->socket, - data + offset, - data_size - offset); + n = Seobeo_Socket_Write( + p_handle->socket, + data + offset, + data_size - offset); } if (n==0)
--- a/seobeo/s_web.c Sun Aug 02 09:07:36 2026 -0700 +++ b/seobeo/s_web.c Sun Aug 02 10:07:40 2026 -0700 @@ -51,6 +51,7 @@ { case HTTP_OK: status_text = "OK"; break; case HTTP_CREATED: status_text = "Created"; break; + case HTTP_NO_CONTENT: status_text = "No Content"; break; case HTTP_MOVED_PERMANENTLY: status_text = "Moved Permanently"; break; case HTTP_FOUND: status_text = "Found"; break; case HTTP_BAD_REQUEST: status_text = "Bad Request"; break;
--- a/seobeo/seobeo.h Sun Aug 02 09:07:36 2026 -0700 +++ b/seobeo/seobeo.h Sun Aug 02 10:07:40 2026 -0700 @@ -30,6 +30,7 @@ // HTTP STATUS CODE #define HTTP_OK 200 #define HTTP_CREATED 201 +#define HTTP_NO_CONTENT 204 #define HTTP_MOVED_PERMANENTLY 301 #define HTTP_FOUND 302 #define HTTP_BAD_REQUEST 400
--- a/seobeo/tests/BUILD Sun Aug 02 09:07:36 2026 -0700 +++ b/seobeo/tests/BUILD Sun Aug 02 10:07:40 2026 -0700 @@ -39,6 +39,15 @@ ) cc_test( + name = "seobeo_sigpipe_test", + srcs = ["seobeo_sigpipe_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( name = "seobeo_http_timeout_test", srcs = ["seobeo_http_timeout_test.c"], deps = ["//seobeo:seobeo"],
--- a/seobeo/tests/seobeo_response_test.c Sun Aug 02 09:07:36 2026 -0700 +++ b/seobeo/tests/seobeo_response_test.c Sun Aug 02 10:07:40 2026 -0700 @@ -45,5 +45,35 @@ close(sockets[1]); free(handle.write_buffer); Dowa_Arena_Free(arena); + + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + memset(&handle, 0, sizeof(handle)); + handle.socket = sockets[0]; + handle.write_buffer_capacity = 4096; + handle.write_buffer = malloc(handle.write_buffer_capacity); + arena = Dowa_Arena_Create(4096); + response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "204", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "", arena); + Seobeo_Router_Send_Response(&handle, response, arena); + + memset(received, 0, sizeof(received)); + length = read(sockets[1], received, sizeof(received) - 1); + if (length <= 0 || + !strstr(received, "HTTP/1.1 204 No Content\r\n") || + !strstr(received, "Content-Length: 0\r\n") || + !strstr(received, "\r\n\r\n")) + failed = 1; + + if (failed) + fprintf(stderr, "Unexpected no-content response:\n%s\n", received); + + close(sockets[0]); + close(sockets[1]); + free(handle.write_buffer); + Dowa_Arena_Free(arena); return failed; }
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_sigpipe_test.c Sun Aug 02 10:07:40 2026 -0700 @@ -0,0 +1,36 @@ +#include "seobeo/seobeo.h" + +#include <signal.h> +#include <stdlib.h> +#include <sys/socket.h> + +int main(void) +{ + Seobeo_Handle *server = + Seobeo_Stream_Handle_Server_Create("127.0.0.1", "0"); + if (!server) + return 1; + + raise(SIGPIPE); + Seobeo_Handle_Destroy(server); + + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + Seobeo_Handle handle = {0}; + handle.socket = sockets[0]; + handle.write_buffer_capacity = 64; + handle.write_buffer = malloc(handle.write_buffer_capacity); + if (!handle.write_buffer) + return 1; + + close(sockets[1]); + if (Seobeo_Handle_Queue(&handle, (const uint8 *)"closed", 6) != 0) + return 1; + + int result = Seobeo_Handle_Flush(&handle); + close(sockets[0]); + free(handle.write_buffer); + return result < 0 ? 0 : 1; +}