fix(tests): stabilize contract digests across line endings

This commit is contained in:
rookiestar28
2026-07-11 17:25:34 +08:00
parent a28316ed38
commit c1aca449c5
13 changed files with 147 additions and 26 deletions
+10
View File
@@ -0,0 +1,10 @@
/** Portable digest helpers for governed UTF-8 text contracts. */
import crypto from "node:crypto";
import fs from "node:fs";
export function stableTextDigest(filePath) {
// IMPORTANT: normalize text newlines; raw hashing breaks frozen contracts after Windows checkout.
const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n?/g, "\n");
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
}
+23
View File
@@ -0,0 +1,23 @@
"""Portable digest and write helpers for governed text contracts."""
from __future__ import annotations
import hashlib
from pathlib import Path
def normalize_text_newlines(payload: bytes) -> bytes:
"""Return text bytes with CRLF and lone CR represented as LF."""
# IMPORTANT: normalize text newlines; raw hashing breaks frozen contracts after Windows checkout.
return payload.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
def stable_text_digest(path: Path) -> str:
"""Hash governed text independently of checkout newline representation."""
return hashlib.sha256(normalize_text_newlines(path.read_bytes())).hexdigest()
def write_text_lf(path: Path, text: str) -> None:
"""Write UTF-8 contract text with explicit LF newlines on every platform."""
with path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(text)
+5 -5
View File
@@ -14,6 +14,8 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
CONTRACT_PATH = ROOT / "tests" / "api_config_contract_r221.json"
@@ -126,10 +128,8 @@ def build_contract() -> dict[str, Any]:
"tests.test_r219_exception_boundary_phase2",
],
},
"r220_route_contract_sha256": hashlib.sha256(
route_contract.read_bytes()
).hexdigest(),
"openapi_sha256": hashlib.sha256(openapi.read_bytes()).hexdigest(),
"r220_route_contract_sha256": stable_text_digest(route_contract),
"openapi_sha256": stable_text_digest(openapi),
}
@@ -139,7 +139,7 @@ def main() -> int:
args = parser.parse_args()
actual = build_contract()
if args.write_baseline:
CONTRACT_PATH.write_text(_canonical_json(actual), encoding="utf-8")
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
print(f"API-CONFIG-CONTRACT-WRITTEN: {CONTRACT_PATH}")
return 0
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
+4 -4
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import argparse
import hashlib
import inspect
import json
import sys
@@ -15,6 +14,8 @@ ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
CONTRACT_PATH = ROOT / "tests" / "api_route_contract_r220.json"
@@ -122,7 +123,6 @@ def build_contract() -> dict[str, Any]:
"trace_handler",
)
}
openapi_bytes = (ROOT / "docs" / "openapi.yaml").read_bytes()
return {
"schema_version": 1,
"registration_order": [
@@ -149,7 +149,7 @@ def build_contract() -> dict[str, Any]:
"families": families,
"facade_signatures": facade,
"facade_metadata": metadata,
"openapi_sha256": hashlib.sha256(openapi_bytes).hexdigest(),
"openapi_sha256": stable_text_digest(ROOT / "docs" / "openapi.yaml"),
}
@@ -163,7 +163,7 @@ def main() -> int:
args = parser.parse_args()
actual = build_contract()
if args.write_baseline:
CONTRACT_PATH.write_text(_canonical_json(actual), encoding="utf-8")
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
print(f"API-ROUTE-CONTRACT-WRITTEN: {CONTRACT_PATH}")
return 0
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
+5 -3
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import argparse
import ast
import hashlib
import inspect
import json
import sys
@@ -15,6 +14,9 @@ from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
CONTRACT_PATH = ROOT / "tests" / "connector_router_contract_r222.json"
@@ -75,7 +77,7 @@ def build_contract() -> dict[str, Any]:
"api_route_contract_r220.json",
):
path = ROOT / "tests" / filename
digests[filename] = hashlib.sha256(path.read_bytes()).hexdigest()
digests[filename] = stable_text_digest(path)
return {
"schema_version": 1,
"constructor_signature": str(inspect.signature(CommandRouter)),
@@ -116,7 +118,7 @@ def main() -> int:
args = parser.parse_args()
actual = build_contract()
if args.write_baseline:
CONTRACT_PATH.write_text(_canonical_json(actual), encoding="utf-8")
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
print(f"CONNECTOR-ROUTER-CONTRACT-WRITTEN: {CONTRACT_PATH}")
return 0
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
@@ -1,9 +1,9 @@
/** Verify the frozen R224 Settings/API frontend contract. */
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { stableTextDigest } from "./contract_digest.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const CONTRACT_PATH = path.join(ROOT, "web", "tests", "fixtures", "frontend_decomposition_contract_r224.json");
@@ -42,7 +42,7 @@ function methodSignatures(source) {
}
function digest(relativePath) {
return crypto.createHash("sha256").update(fs.readFileSync(path.join(ROOT, relativePath))).digest("hex");
return stableTextDigest(path.join(ROOT, relativePath));
}
export function buildContract() {
+5 -5
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import argparse
import ast
import hashlib
import inspect
import json
import sys
@@ -15,6 +14,9 @@ from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.contract_digest import stable_text_digest, write_text_lf # noqa: E402
CONTRACT_PATH = ROOT / "tests" / "platform_adapter_contract_r223.json"
@@ -131,9 +133,7 @@ def build_contract() -> dict[str, Any]:
"tests.test_f74_reply_visibility_policy",
"tests.security.test_s80_connector_ingress",
],
"router_contract_digest": hashlib.sha256(
router_contract.read_bytes()
).hexdigest(),
"router_contract_digest": stable_text_digest(router_contract),
}
@@ -143,7 +143,7 @@ def main() -> int:
args = parser.parse_args()
actual = build_contract()
if args.write_baseline:
CONTRACT_PATH.write_text(_canonical_json(actual), encoding="utf-8")
write_text_lf(CONTRACT_PATH, _canonical_json(actual))
print(f"PLATFORM-ADAPTER-CONTRACT-WRITTEN: {CONTRACT_PATH}")
return 0
expected = json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
+1 -1
View File
@@ -199,7 +199,7 @@
"requires_key": true
}
],
"r220_route_contract_sha256": "17c804dc80f35ddf774e5a941ab4fd4404f55f1e37a8cafd3b3a9238a5e12c9e",
"r220_route_contract_sha256": "947979f978f3541dce6b005c38255177322234a24b47ee0dd22a49e234eeed27",
"schema_version": 1,
"settings_schema_sha256": "e129472bd8b4fb81181c2a3169ed6177757cb54050276646eea70052007bd10b"
}
+2 -2
View File
@@ -166,7 +166,7 @@
],
"schema_version": 1,
"upstream_contract_digests": {
"api_config_contract_r221.json": "496063d3a838c5cfa884952215049054cdf6ff030c98a5b02802ae7f64f2d57e",
"api_route_contract_r220.json": "17c804dc80f35ddf774e5a941ab4fd4404f55f1e37a8cafd3b3a9238a5e12c9e"
"api_config_contract_r221.json": "a0a4aec0ba68692bf8bfac411fb951f95589006ba3d5d6ad463569d24dfc1245",
"api_route_contract_r220.json": "947979f978f3541dce6b005c38255177322234a24b47ee0dd22a49e234eeed27"
}
}
+1 -1
View File
@@ -90,7 +90,7 @@
"tests.test_f74_reply_visibility_policy",
"tests.security.test_s80_connector_ingress"
],
"router_contract_digest": "78360537c9129f1671d1c62b663b1ea8a53d5b29fe067e2d73ab7c6bad5824aa",
"router_contract_digest": "1ba1ba79dba58571f120a244923ef957ce367ddde44f74d690611f6306a6dde7",
"schema_version": 1,
"slack": {
"class_constants": {
@@ -0,0 +1,47 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
from pathlib import Path
class ContractDigestPortabilityTests(unittest.TestCase):
def test_text_digest_is_newline_invariant_but_content_sensitive(self) -> None:
spec = importlib.util.find_spec("scripts.contract_digest")
self.assertIsNotNone(spec, "shared contract digest helper must exist")
from scripts.contract_digest import stable_text_digest
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
variants = {
"lf.txt": b"alpha\nbeta\n",
"crlf.txt": b"alpha\r\nbeta\r\n",
"cr.txt": b"alpha\rbeta\r",
}
for name, payload in variants.items():
(root / name).write_bytes(payload)
(root / "changed.txt").write_bytes(b"alpha\ngamma\n")
normalized = {stable_text_digest(root / name) for name in variants}
self.assertEqual(len(normalized), 1)
self.assertNotEqual(
stable_text_digest(root / "lf.txt"),
stable_text_digest(root / "changed.txt"),
)
def test_text_writer_emits_utf8_lf_bytes(self) -> None:
spec = importlib.util.find_spec("scripts.contract_digest")
self.assertIsNotNone(spec, "shared contract digest helper must exist")
from scripts.contract_digest import write_text_lf
with tempfile.TemporaryDirectory() as temp_dir:
target = Path(temp_dir) / "contract.json"
write_text_lf(target, '{\n "label": "測試"\n}\n')
payload = target.read_bytes()
self.assertNotIn(b"\r", payload)
self.assertEqual(payload.decode("utf-8"), '{\n "label": "測試"\n}\n')
if __name__ == "__main__":
unittest.main()
@@ -173,8 +173,8 @@
]
},
"upstream_contract_digests": {
"tests/api_route_contract_r220.json": "17c804dc80f35ddf774e5a941ab4fd4404f55f1e37a8cafd3b3a9238a5e12c9e",
"tests/api_config_contract_r221.json": "496063d3a838c5cfa884952215049054cdf6ff030c98a5b02802ae7f64f2d57e",
"tests/platform_adapter_contract_r223.json": "a23402432a51a57ad13a5de777f3c34b6d58743179524c09a90b004f98f3dd2c"
"tests/api_route_contract_r220.json": "947979f978f3541dce6b005c38255177322234a24b47ee0dd22a49e234eeed27",
"tests/api_config_contract_r221.json": "a0a4aec0ba68692bf8bfac411fb951f95589006ba3d5d6ad463569d24dfc1245",
"tests/platform_adapter_contract_r223.json": "fb72a77ad8b0ce693c27b34d757f5d47d5be0ebcdd39732c34a1ee535f899b6a"
}
}
@@ -0,0 +1,39 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const helperPath = path.resolve(process.cwd(), "scripts", "contract_digest.mjs");
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe("contract digest portability", () => {
it("normalizes text newlines without hiding content changes", async () => {
expect(fs.existsSync(helperPath), "shared contract digest helper must exist").toBe(true);
const { stableTextDigest } = await import(helperPath);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-contract-digest-"));
temporaryDirectories.push(root);
const variants = {
"lf.txt": "alpha\nbeta\n",
"crlf.txt": "alpha\r\nbeta\r\n",
"cr.txt": "alpha\rbeta\r",
};
for (const [name, content] of Object.entries(variants)) {
fs.writeFileSync(path.join(root, name), content, "utf8");
}
fs.writeFileSync(path.join(root, "changed.txt"), "alpha\ngamma\n", "utf8");
const normalized = new Set(
Object.keys(variants).map((name) => stableTextDigest(path.join(root, name))),
);
expect(normalized.size).toBe(1);
expect(stableTextDigest(path.join(root, "changed.txt"))).not.toBe(
stableTextDigest(path.join(root, "lf.txt")),
);
});
});