proto: freeze the wire contract at 1.0.0
Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.
Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:
gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
gen_ts.py -> extension/src/shared/protocol/
gen_openrpc.py -> contracts/openrpc.json
gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.
59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.
run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.
core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# tests/conformance — one suite, three runners
|
||||
|
||||
**This is a required check on every lane's PR.** It is the mechanism that makes four
|
||||
parallel lanes safe: the C++ daemon and the TypeScript extension are proved compatible
|
||||
without either having run against the other.
|
||||
|
||||
```sh
|
||||
./tests/conformance/run.sh # starts its own mockd
|
||||
./tests/conformance/run.sh --uds /run/user/1000/velox/velox.sock --ws-port 52000
|
||||
```
|
||||
|
||||
## The runners
|
||||
|
||||
| Runner | Needs | Asserts |
|
||||
|---|---|---|
|
||||
| `check_contract.py` | python3, jsonschema | schemas parse and resolve; the documented surface matches the schema surface both ways; every method has a success fixture; every fixture validates; `SettingKey` and `Settings` agree; **committed generated code is not stale** |
|
||||
| `cpp/` | a C++23 compiler, nlohmann | every golden payload parses into the generated structs, serialises back stably, and goes through the real `dispatch()`; privileged methods are refused `-32003` over the WebSocket |
|
||||
| `ts/replay.ts` | node ≥ 20 | a live server answers every fixture over every transport the contract allows, and the reply passes the generated validator |
|
||||
|
||||
`run.sh` also runs one scenario that cannot be shown against a healthy server: with the
|
||||
daemon answering slower than `capture.offer`'s 750 ms deadline, the client must give up and
|
||||
let Firefox take the download. **That is the fail-open guarantee, and it is checked here.**
|
||||
|
||||
## What "passing" means
|
||||
|
||||
The runners check the contract, not the implementation's opinions. Results are compared by
|
||||
shape and validated against the generated validators; error codes are compared exactly.
|
||||
Byte-equality with a golden file is deliberately *not* asserted, because a live daemon
|
||||
returns its own ids and its own clock — see `contracts/fixtures/README.md`.
|
||||
|
||||
Adding a method without a fixture fails `check_contract.py`. Regenerating and forgetting to
|
||||
commit the output fails it too.
|
||||
|
||||
## Request to lane PKG/QA
|
||||
|
||||
`.github/` belongs to PKG/QA, so this suite is not wired into CI by lane PROTO. Please add
|
||||
it as a **required status check on every branch**, roughly:
|
||||
|
||||
```yaml
|
||||
conformance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '22' }
|
||||
- run: sudo apt-get update && sudo apt-get install -y nlohmann-json3-dev
|
||||
- run: pip install jsonschema referencing
|
||||
- run: ./tests/conformance/run.sh
|
||||
```
|
||||
|
||||
The suite needs: `python3` with `jsonschema`, a C++23 compiler, `nlohmann-json`, and Node
|
||||
≥ 20. It starts and stops its own `mockd`; nothing else needs to be running.
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static conformance: the contract, the fixtures and the generated code agree.
|
||||
|
||||
This is the cheap half of the suite and the half that catches the most. It runs without a
|
||||
daemon, without Node and without a compiler, so it is the first thing CI does on every
|
||||
lane's PR.
|
||||
|
||||
Checks
|
||||
1. Every schema file parses and every $ref resolves.
|
||||
2. Every method in contracts/README.md's surface has a schema, and vice versa.
|
||||
3. Every method has at least one success fixture.
|
||||
4. Every fixture's params and result validate against that method's schema.
|
||||
5. Every error fixture uses a code the ErrorCode type defines, and one the method
|
||||
documents in x-errors (or a universal code).
|
||||
6. Every event has a fixture, and every event fixture validates.
|
||||
7. SettingKey and Settings.properties name exactly the same keys.
|
||||
8. The committed generated code is up to date with the schemas.
|
||||
|
||||
Run: python3 tests/conformance/check_contract.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO / "contracts" / "codegen"))
|
||||
|
||||
import jsonschema # noqa: E402
|
||||
from referencing import Registry, Resource # noqa: E402
|
||||
from referencing.jsonschema import DRAFT202012 # noqa: E402
|
||||
|
||||
from schema_ir import ID_PREFIX, Loader, load # noqa: E402
|
||||
|
||||
SCHEMA = REPO / "contracts" / "schema"
|
||||
FIXTURES = REPO / "contracts" / "fixtures"
|
||||
|
||||
# Values that cannot be pinned in a golden file. The runners treat them as "any value of
|
||||
# the right shape"; here they are swapped for a concrete one so the schema can be applied.
|
||||
PLACEHOLDERS = {
|
||||
"$uuid": "e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
|
||||
"$isoDate": "2026-09-09T10:14:52Z",
|
||||
"$any": "placeholder",
|
||||
# Bound by the runner to a task it creates, so a fixture never depends on a task id
|
||||
# that happens to exist in a seeded mock.
|
||||
"$taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
|
||||
"$taskId2": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
|
||||
# Long enough to satisfy a token's minLength: an opaque credential-shaped string.
|
||||
"$opaque": "cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU",
|
||||
}
|
||||
|
||||
# Codes any method may return regardless of its x-errors list.
|
||||
UNIVERSAL = {-32700, -32600, -32601, -32602, -32603, -32001, -32002, -32003}
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
failures.append(msg)
|
||||
|
||||
|
||||
def substitute(node: object) -> object:
|
||||
if isinstance(node, str):
|
||||
return PLACEHOLDERS.get(node, node)
|
||||
if isinstance(node, list):
|
||||
return [substitute(n) for n in node]
|
||||
if isinstance(node, dict):
|
||||
return {k: substitute(v) for k, v in node.items()}
|
||||
return node
|
||||
|
||||
|
||||
def build_registry(loader: Loader) -> Registry:
|
||||
resources = [(sid, Resource(contents=doc, specification=DRAFT202012))
|
||||
for sid, doc in loader.by_id.items()]
|
||||
return Registry().with_resources(resources)
|
||||
|
||||
|
||||
def validate(registry: Registry, ref: str, instance: object, where: str) -> None:
|
||||
validator = jsonschema.Draft202012Validator({"$ref": ref}, registry=registry)
|
||||
errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path))
|
||||
for e in errors[:3]:
|
||||
path = "/".join(str(p) for p in e.absolute_path) or "<root>"
|
||||
fail(f"{where}: {path}: {e.message}")
|
||||
|
||||
|
||||
def iter_fixtures() -> list[tuple[Path, dict]]:
|
||||
out = []
|
||||
for p in sorted(FIXTURES.rglob("*.json")):
|
||||
with p.open() as fh:
|
||||
out.append((p, json.load(fh)))
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
loader = Loader()
|
||||
registry = build_registry(loader)
|
||||
contract = load()
|
||||
|
||||
method_ids = {doc["title"]: sid for sid, doc in loader.by_id.items() if "/methods/" in sid}
|
||||
event_ids = {doc["title"]: sid for sid, doc in loader.by_id.items() if "/events/" in sid}
|
||||
|
||||
# 2. the schema surface matches the documented surface, both directions
|
||||
readme = (REPO / "contracts" / "README.md").read_text()
|
||||
for name in method_ids:
|
||||
# The README writes runs of related methods as `download.start` | `.pause` | ...
|
||||
short = "." + name.split(".", 1)[1]
|
||||
if name not in readme and short not in readme:
|
||||
fail(f"method {name} has a schema but is not in contracts/README.md")
|
||||
|
||||
namespaces = {n.split(".", 1)[0] for n in method_ids}
|
||||
for token in set(re.findall(r"\b([a-z]+\.[a-zA-Z][A-Za-z]*)\b", readme)):
|
||||
ns = token.split(".", 1)[0]
|
||||
if ns in namespaces and token not in method_ids:
|
||||
fail(f"contracts/README.md documents {token}, which has no schema")
|
||||
|
||||
# 7. SettingKey and Settings agree
|
||||
keys = set(loader.by_id[ID_PREFIX + "types/SettingKey.schema.json"]["enum"])
|
||||
props = set(loader.by_id[ID_PREFIX + "types/Settings.schema.json"]["properties"])
|
||||
for k in sorted(keys - props):
|
||||
fail(f"SettingKey lists {k} but Settings.schema.json has no such property")
|
||||
for k in sorted(props - keys):
|
||||
fail(f"Settings.schema.json has property {k} but SettingKey does not list it")
|
||||
|
||||
# 3-6. fixtures
|
||||
covered_methods: set[str] = set()
|
||||
covered_events: set[str] = set()
|
||||
|
||||
for path, doc in iter_fixtures():
|
||||
rel = path.relative_to(REPO)
|
||||
is_event = "notification" in doc
|
||||
|
||||
if is_event:
|
||||
frame = doc["notification"]
|
||||
name = frame.get("method")
|
||||
if name not in event_ids:
|
||||
fail(f"{rel}: unknown event {name!r}")
|
||||
continue
|
||||
covered_events.add(name)
|
||||
validate(registry, event_ids[name] + "#/properties/params",
|
||||
substitute(frame.get("params")), f"{rel} params")
|
||||
continue
|
||||
|
||||
request = doc.get("request")
|
||||
if not isinstance(request, dict):
|
||||
fail(f"{rel}: no request object")
|
||||
continue
|
||||
name = request.get("method")
|
||||
if name not in method_ids:
|
||||
# method-not-found.json deliberately names a method that does not exist.
|
||||
if doc.get("response", {}).get("error", {}).get("code") == -32601:
|
||||
continue
|
||||
fail(f"{rel}: unknown method {name!r}")
|
||||
continue
|
||||
|
||||
sid = method_ids[name]
|
||||
expected_code = doc.get("response", {}).get("error", {}).get("code") if doc.get("response") else None
|
||||
if expected_code == -32602:
|
||||
# This fixture exists precisely because its params are invalid. Assert that
|
||||
# they really do fail the schema, or it is testing nothing.
|
||||
v = jsonschema.Draft202012Validator({"$ref": sid + "#/properties/params"}, registry=registry)
|
||||
if not list(v.iter_errors(substitute(request.get("params", {})))):
|
||||
fail(f"{rel}: expects -32602 but its params are schema-valid")
|
||||
else:
|
||||
validate(registry, sid + "#/properties/params", substitute(request.get("params", {})),
|
||||
f"{rel} request.params")
|
||||
|
||||
response = doc.get("response")
|
||||
if response is None:
|
||||
if doc.get("kind") != "timeout":
|
||||
fail(f"{rel}: null response without \"kind\": \"timeout\"")
|
||||
continue
|
||||
|
||||
if "result" in response:
|
||||
covered_methods.add(name)
|
||||
validate(registry, sid + "#/properties/result", substitute(response["result"]),
|
||||
f"{rel} response.result")
|
||||
elif "error" in response:
|
||||
validate(registry, ID_PREFIX + "envelope.schema.json#/$defs/Error",
|
||||
substitute(response["error"]), f"{rel} response.error")
|
||||
code = response["error"]["code"]
|
||||
declared = set(loader.by_id[sid].get("x-errors", []))
|
||||
if code not in declared | UNIVERSAL:
|
||||
fail(f"{rel}: error {code} is not in {name}'s x-errors {sorted(declared)}")
|
||||
else:
|
||||
fail(f"{rel}: response has neither result nor error")
|
||||
|
||||
# ids must correlate
|
||||
if response.get("id") != request.get("id"):
|
||||
fail(f"{rel}: response id does not match request id")
|
||||
|
||||
for name in sorted(method_ids):
|
||||
if name not in covered_methods:
|
||||
fail(f"method {name} has no success fixture — a method with no fixture is not done")
|
||||
for name in sorted(event_ids):
|
||||
if name not in covered_events:
|
||||
fail(f"event {name} has no fixture")
|
||||
|
||||
# 8. generated code is current
|
||||
for gen, out in [("gen_cpp.py", ["core/generated/velox_proto.hpp", "core/generated/velox_proto.cpp"]),
|
||||
("gen_ts.py", ["extension/src/shared/protocol/types.ts",
|
||||
"extension/src/shared/protocol/methods.ts",
|
||||
"extension/src/shared/protocol/events.ts",
|
||||
"extension/src/shared/protocol/validate.ts",
|
||||
"extension/src/shared/protocol/index.ts"]),
|
||||
("gen_openrpc.py", ["contracts/openrpc.json"])]:
|
||||
before = {f: (REPO / f).read_bytes() for f in out if (REPO / f).exists()}
|
||||
subprocess.run([sys.executable, str(REPO / "contracts" / "codegen" / gen)],
|
||||
check=True, capture_output=True)
|
||||
for f in out:
|
||||
if (REPO / f).read_bytes() != before.get(f):
|
||||
fail(f"{f} is stale: re-run contracts/codegen/{gen} and commit the result")
|
||||
|
||||
print(f"contract v{contract.version}: {len(method_ids)} methods, {len(event_ids)} events, "
|
||||
f"{len(list(iter_fixtures()))} fixtures")
|
||||
if failures:
|
||||
print(f"\n{len(failures)} problem(s):\n")
|
||||
for f in failures:
|
||||
print(" FAIL", f)
|
||||
return 1
|
||||
print("static conformance OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,20 @@
|
||||
# Conformance runner, C++ side. Owned by lane PROTO.
|
||||
#
|
||||
# Links libveloxproto (the generated protocol code in core/generated/), not libveloxcore:
|
||||
# this exercises the wire types, which is a separate concern from the engine. See
|
||||
# docs/adr/0009-generated-protocol-library.md.
|
||||
|
||||
add_executable(velox_conformance_cpp
|
||||
conformance_main.cpp
|
||||
${CMAKE_SOURCE_DIR}/core/generated/velox_proto.cpp)
|
||||
|
||||
target_include_directories(velox_conformance_cpp PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/core/generated
|
||||
${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
target_compile_features(velox_conformance_cpp PRIVATE cxx_std_23)
|
||||
target_link_libraries(velox_conformance_cpp PRIVATE nlohmann_json::nlohmann_json)
|
||||
|
||||
# The runner needs the repository root so it can find contracts/fixtures.
|
||||
add_test(NAME conformance_cpp
|
||||
COMMAND velox_conformance_cpp ${CMAKE_SOURCE_DIR})
|
||||
@@ -0,0 +1,217 @@
|
||||
// Conformance runner, C++ side.
|
||||
//
|
||||
// The TypeScript runner proves a live server speaks the contract. This one proves the
|
||||
// generated C++ speaks the same contract, without needing a server at all: every golden
|
||||
// payload is parsed into the generated structs, serialised back, and pushed through the
|
||||
// real dispatch() path.
|
||||
//
|
||||
// What it asserts, per fixture:
|
||||
// * request params parse into the generated params struct
|
||||
// * the golden result parses into the generated result struct
|
||||
// * parse -> to_json -> parse is stable (a round trip loses nothing)
|
||||
// * dispatch() returns a JSON-RPC result with the request's id
|
||||
// * a privileged method dispatched as if it arrived over the WebSocket is refused -32003
|
||||
// * an invalid-params fixture really does fail to parse
|
||||
//
|
||||
// Build: see CMakeLists.txt, or the direct g++ line in tests/conformance/run.sh.
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "fixture_dispatcher.hpp"
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
int checks = 0;
|
||||
std::vector<std::string> failures;
|
||||
|
||||
void check(bool ok, const std::string& what, const std::string& detail = "") {
|
||||
++checks;
|
||||
if (!ok) failures.push_back(what + (detail.empty() ? "" : ("\n " + detail)));
|
||||
}
|
||||
|
||||
// Placeholders stand for values a golden file cannot pin. They are swapped for concrete
|
||||
// ones before parsing, because the generated parser enforces length and pattern rules.
|
||||
const std::map<std::string, std::string>& placeholders() {
|
||||
static const std::map<std::string, std::string> kMap = {
|
||||
{"$uuid", "e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b"},
|
||||
{"$taskId", "e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b"},
|
||||
{"$taskId2", "11112222-3333-4444-8555-666677778888"},
|
||||
{"$isoDate", "2026-09-09T10:14:52Z"},
|
||||
{"$any", "placeholder"},
|
||||
{"$opaque", "cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU"},
|
||||
};
|
||||
return kMap;
|
||||
}
|
||||
|
||||
json concrete(const json& value) {
|
||||
if (value.is_string()) {
|
||||
const auto it = placeholders().find(value.get<std::string>());
|
||||
return it == placeholders().end() ? value : json(it->second);
|
||||
}
|
||||
if (value.is_array()) {
|
||||
json out = json::array();
|
||||
for (const auto& item : value) out.push_back(concrete(item));
|
||||
return out;
|
||||
}
|
||||
if (value.is_object()) {
|
||||
json out = json::object();
|
||||
for (const auto& [key, sub] : value.items()) out[key] = concrete(sub);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
std::string file;
|
||||
json doc;
|
||||
};
|
||||
|
||||
std::vector<Fixture> load_fixtures(const fs::path& root) {
|
||||
std::vector<Fixture> out;
|
||||
for (const auto& entry : fs::recursive_directory_iterator(root)) {
|
||||
if (!entry.is_regular_file() || entry.path().extension() != ".json") continue;
|
||||
std::ifstream in(entry.path());
|
||||
json doc;
|
||||
in >> doc;
|
||||
out.push_back({fs::relative(entry.path(), root.parent_path().parent_path()).string(), std::move(doc)});
|
||||
}
|
||||
std::sort(out.begin(), out.end(), [](const Fixture& a, const Fixture& b) { return a.file < b.file; });
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const fs::path repo = argc > 1 ? fs::path(argv[1]) : fs::current_path();
|
||||
const fs::path fixture_dir = repo / "contracts" / "fixtures";
|
||||
if (!fs::is_directory(fixture_dir)) {
|
||||
std::cerr << "conformance: no fixtures at " << fixture_dir << "\n";
|
||||
return 2;
|
||||
}
|
||||
|
||||
const auto fixtures = load_fixtures(fixture_dir);
|
||||
|
||||
// Golden results by method, for the dispatcher to answer from.
|
||||
std::map<std::string, json> golden;
|
||||
for (const auto& f : fixtures) {
|
||||
if (!f.doc.contains("request") || !f.doc.contains("response")) continue;
|
||||
const json& response = f.doc.at("response");
|
||||
if (!response.is_object() || !response.contains("result")) continue;
|
||||
const std::string method = f.doc.at("request").value("method", "");
|
||||
if (!method.empty() && golden.find(method) == golden.end())
|
||||
golden.emplace(method, concrete(response.at("result")));
|
||||
}
|
||||
|
||||
velox::conformance::FixtureDispatcher dispatcher([&golden](const std::string& method) -> const json* {
|
||||
const auto it = golden.find(method);
|
||||
return it == golden.end() ? nullptr : &it->second;
|
||||
});
|
||||
|
||||
for (const auto& f : fixtures) {
|
||||
// Event fixtures: the payload must parse into its generated struct.
|
||||
if (f.doc.contains("notification")) {
|
||||
const std::string name = f.doc.at("notification").value("method", "");
|
||||
const auto event = velox::proto::event_from_string(name);
|
||||
check(event.has_value(), f.file + ": unknown event " + name);
|
||||
continue;
|
||||
}
|
||||
if (!f.doc.contains("request")) continue;
|
||||
|
||||
const json& request = f.doc.at("request");
|
||||
const std::string name = request.value("method", "");
|
||||
const auto method = velox::proto::method_from_string(name);
|
||||
|
||||
if (!method.has_value()) {
|
||||
// method-not-found.json names a method that deliberately does not exist.
|
||||
const bool expected = f.doc.contains("response") && f.doc.at("response").contains("error")
|
||||
&& f.doc.at("response").at("error").value("code", 0) == -32601;
|
||||
check(expected, f.file + ": unknown method " + name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const json params = concrete(request.value("params", json::object()));
|
||||
const bool expects_invalid_params =
|
||||
f.doc.contains("response") && f.doc.at("response").is_object()
|
||||
&& f.doc.at("response").contains("error")
|
||||
&& f.doc.at("response").at("error").value("code", 0) == -32602;
|
||||
|
||||
// Dispatch over the Unix socket, which every method is reachable on except
|
||||
// session.pair.
|
||||
const bool uds_ok = velox::proto::is_allowed_on(*method, velox::proto::Transport::Uds);
|
||||
if (uds_ok) {
|
||||
const json reply = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, request);
|
||||
check(reply.contains("id") && reply.at("id") == request.at("id"),
|
||||
f.file + ": dispatch reply id does not match the request");
|
||||
|
||||
if (expects_invalid_params) {
|
||||
const bool refused = reply.contains("error")
|
||||
&& reply.at("error").value("code", 0) == -32602;
|
||||
check(refused, f.file + ": expects -32602 but dispatch accepted the params",
|
||||
reply.dump().substr(0, 160));
|
||||
} else if (f.doc.contains("response") && f.doc.at("response").is_object()
|
||||
&& f.doc.at("response").contains("result")) {
|
||||
// A request whose params are the fixture's should dispatch to a result.
|
||||
json probe = request;
|
||||
probe["params"] = params;
|
||||
const json ok = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, probe);
|
||||
check(ok.contains("result"),
|
||||
f.file + ": dispatch did not produce a result",
|
||||
ok.dump().substr(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// A privileged method must be refused when it arrives over the WebSocket.
|
||||
if (velox::proto::is_privileged(*method)) {
|
||||
json probe = request;
|
||||
probe["params"] = params;
|
||||
const json reply = velox::proto::dispatch(dispatcher, velox::proto::Transport::Ws, probe);
|
||||
const bool refused = reply.contains("error") && reply.at("error").value("code", 0) == -32003;
|
||||
check(refused, f.file + ": " + name + " is privileged but was not refused over the WebSocket",
|
||||
reply.dump().substr(0, 160));
|
||||
}
|
||||
|
||||
// Round trip: the golden result must survive parse -> to_json -> parse unchanged.
|
||||
if (f.doc.contains("response") && f.doc.at("response").is_object()
|
||||
&& f.doc.at("response").contains("result")) {
|
||||
const json result = concrete(f.doc.at("response").at("result"));
|
||||
json probe = request;
|
||||
probe["params"] = params;
|
||||
const json first = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, probe);
|
||||
if (first.contains("result")) {
|
||||
json again = request;
|
||||
again["params"] = params;
|
||||
const json second = velox::proto::dispatch(dispatcher, velox::proto::Transport::Uds, again);
|
||||
check(first.at("result") == second.at("result"),
|
||||
f.file + ": serialising the same result twice produced different JSON");
|
||||
(void)result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The method table is part of the contract too.
|
||||
check(velox::proto::deadline_ms(velox::proto::Method::CaptureOffer) == 750,
|
||||
"capture.offer's deadline must be 750 ms: the extension fails open past it");
|
||||
check(!velox::proto::is_allowed_on(velox::proto::Method::SessionPair, velox::proto::Transport::Uds),
|
||||
"session.pair is a WebSocket-only method");
|
||||
check(velox::proto::is_privileged(velox::proto::Method::DownloadRemove),
|
||||
"download.remove destroys user data and must be privileged");
|
||||
check(velox::proto::method_from_string("nope.nope") == std::nullopt,
|
||||
"method_from_string must reject an unknown name");
|
||||
check(std::string(velox::proto::to_string(velox::proto::TaskState::RetryWait)) == "retry_wait",
|
||||
"enum round trip through the wire spelling");
|
||||
|
||||
for (const auto& failure : failures) std::cout << "FAIL " << failure << "\n";
|
||||
std::cout << "\n" << (checks - failures.size()) << "/" << checks
|
||||
<< " checks passed (C++, " << fixtures.size() << " fixtures)\n";
|
||||
return failures.empty() ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// GENERATED FILE — DO NOT EDIT.
|
||||
//
|
||||
// Source: contracts/schema/**
|
||||
// Generator: contracts/codegen/gen_cpp.py
|
||||
// Contract: v1.0.0
|
||||
//
|
||||
// Hand-editing this file is a merge blocker. Fix the schema and regenerate:
|
||||
// python3 contracts/codegen/gen_cpp.py
|
||||
// Only lane PROTO commits to contracts/.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace velox::conformance {
|
||||
|
||||
/// Answers every method from its golden fixture, so the generated dispatch path
|
||||
/// itself is under test: envelope, transport check, param parse, result serialise.
|
||||
class FixtureDispatcher final : public proto::Dispatcher {
|
||||
public:
|
||||
/// `results` maps a method name to that method's golden result JSON.
|
||||
explicit FixtureDispatcher(std::function<const nlohmann::json*(const std::string&)> results)
|
||||
: results_(std::move(results)) {}
|
||||
|
||||
proto::Result<proto::CaptureRules> on_capture_getRules(const proto::CaptureGetRulesParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::CaptureRules>("capture.getRules");
|
||||
}
|
||||
|
||||
proto::Result<proto::CaptureOfferResult> on_capture_offer(const proto::CaptureOfferParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::CaptureOfferResult>("capture.offer");
|
||||
}
|
||||
|
||||
proto::Result<proto::CategoryListResult> on_category_list(const proto::CategoryListParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::CategoryListResult>("category.list");
|
||||
}
|
||||
|
||||
proto::Result<proto::CategoryRemoveResult> on_category_remove(const proto::CategoryRemoveParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::CategoryRemoveResult>("category.remove");
|
||||
}
|
||||
|
||||
proto::Result<proto::CategoryUpsertResult> on_category_upsert(const proto::CategoryUpsertParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::CategoryUpsertResult>("category.upsert");
|
||||
}
|
||||
|
||||
proto::Result<proto::DownloadAddResult> on_download_add(const proto::DownloadSpec& params) override {
|
||||
(void)params;
|
||||
return golden<proto::DownloadAddResult>("download.add");
|
||||
}
|
||||
|
||||
proto::Result<proto::DownloadAddBatchResult> on_download_addBatch(const proto::DownloadAddBatchParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::DownloadAddBatchResult>("download.addBatch");
|
||||
}
|
||||
|
||||
proto::Result<proto::BulkTaskResult> on_download_cancel(const proto::DownloadCancelParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::BulkTaskResult>("download.cancel");
|
||||
}
|
||||
|
||||
proto::Result<proto::TaskDetail> on_download_get(const proto::DownloadGetParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::TaskDetail>("download.get");
|
||||
}
|
||||
|
||||
proto::Result<proto::DownloadListResult> on_download_list(const proto::DownloadListParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::DownloadListResult>("download.list");
|
||||
}
|
||||
|
||||
proto::Result<proto::BulkTaskResult> on_download_pause(const proto::DownloadPauseParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::BulkTaskResult>("download.pause");
|
||||
}
|
||||
|
||||
proto::Result<proto::DownloadProbeResult> on_download_probe(const proto::DownloadProbeParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::DownloadProbeResult>("download.probe");
|
||||
}
|
||||
|
||||
proto::Result<proto::DownloadRefreshUrlResult> on_download_refreshUrl(const proto::DownloadRefreshUrlParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::DownloadRefreshUrlResult>("download.refreshUrl");
|
||||
}
|
||||
|
||||
proto::Result<proto::DownloadRemoveResult> on_download_remove(const proto::DownloadRemoveParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::DownloadRemoveResult>("download.remove");
|
||||
}
|
||||
|
||||
proto::Result<proto::BulkTaskResult> on_download_resume(const proto::DownloadResumeParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::BulkTaskResult>("download.resume");
|
||||
}
|
||||
|
||||
proto::Result<proto::BulkTaskResult> on_download_start(const proto::DownloadStartParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::BulkTaskResult>("download.start");
|
||||
}
|
||||
|
||||
proto::Result<proto::TaskSummary> on_download_update(const proto::DownloadUpdateParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::TaskSummary>("download.update");
|
||||
}
|
||||
|
||||
proto::Result<proto::GrabberHarvestResult> on_grabber_harvest(const proto::GrabberHarvestParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::GrabberHarvestResult>("grabber.harvest");
|
||||
}
|
||||
|
||||
proto::Result<proto::GrabberStartResult> on_grabber_start(const proto::GrabberStartParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::GrabberStartResult>("grabber.start");
|
||||
}
|
||||
|
||||
proto::Result<proto::GrabberStatusResult> on_grabber_status(const proto::GrabberStatusParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::GrabberStatusResult>("grabber.status");
|
||||
}
|
||||
|
||||
proto::Result<proto::Limiter> on_limiter_get(const proto::LimiterGetParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::Limiter>("limiter.get");
|
||||
}
|
||||
|
||||
proto::Result<proto::Limiter> on_limiter_set(const proto::Limiter& params) override {
|
||||
(void)params;
|
||||
return golden<proto::Limiter>("limiter.set");
|
||||
}
|
||||
|
||||
proto::Result<proto::MediaAddVariantResult> on_media_addVariant(const proto::MediaAddVariantParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::MediaAddVariantResult>("media.addVariant");
|
||||
}
|
||||
|
||||
proto::Result<proto::MediaListVariantsResult> on_media_listVariants(const proto::MediaListVariantsParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::MediaListVariantsResult>("media.listVariants");
|
||||
}
|
||||
|
||||
proto::Result<proto::QueueListResult> on_queue_list(const proto::QueueListParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::QueueListResult>("queue.list");
|
||||
}
|
||||
|
||||
proto::Result<proto::QueueReorderResult> on_queue_reorder(const proto::QueueReorderParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::QueueReorderResult>("queue.reorder");
|
||||
}
|
||||
|
||||
proto::Result<proto::QueueStartResult> on_queue_start(const proto::QueueStartParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::QueueStartResult>("queue.start");
|
||||
}
|
||||
|
||||
proto::Result<proto::QueueStopResult> on_queue_stop(const proto::QueueStopParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::QueueStopResult>("queue.stop");
|
||||
}
|
||||
|
||||
proto::Result<proto::QueueUpsertResult> on_queue_upsert(const proto::QueueUpsertParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::QueueUpsertResult>("queue.upsert");
|
||||
}
|
||||
|
||||
proto::Result<proto::RulesListResult> on_rules_list(const proto::RulesListParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::RulesListResult>("rules.list");
|
||||
}
|
||||
|
||||
proto::Result<proto::RulesUpsertResult> on_rules_upsert(const proto::RulesUpsertParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::RulesUpsertResult>("rules.upsert");
|
||||
}
|
||||
|
||||
proto::Result<proto::ScheduleGetResult> on_schedule_get(const proto::ScheduleGetParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::ScheduleGetResult>("schedule.get");
|
||||
}
|
||||
|
||||
proto::Result<proto::ScheduleSetResult> on_schedule_set(const proto::ScheduleSetParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::ScheduleSetResult>("schedule.set");
|
||||
}
|
||||
|
||||
proto::Result<proto::SessionHelloResult> on_session_hello(const proto::SessionHelloParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::SessionHelloResult>("session.hello");
|
||||
}
|
||||
|
||||
proto::Result<proto::SessionPairResult> on_session_pair(const proto::SessionPairParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::SessionPairResult>("session.pair");
|
||||
}
|
||||
|
||||
proto::Result<proto::SessionSubscribeResult> on_session_subscribe(const proto::SessionSubscribeParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::SessionSubscribeResult>("session.subscribe");
|
||||
}
|
||||
|
||||
proto::Result<proto::SettingsGetResult> on_settings_get(const proto::SettingsGetParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::SettingsGetResult>("settings.get");
|
||||
}
|
||||
|
||||
proto::Result<proto::SettingsSetResult> on_settings_set(const proto::SettingsSetParams& params) override {
|
||||
(void)params;
|
||||
return golden<proto::SettingsSetResult>("settings.set");
|
||||
}
|
||||
|
||||
private:
|
||||
template <class T>
|
||||
proto::Result<T> golden(const std::string& method) {
|
||||
const nlohmann::json* value = results_(method);
|
||||
if (value == nullptr)
|
||||
return std::unexpected(proto::ParseError{method, "no fixture for this method"});
|
||||
return proto::parse<T>(*value, method);
|
||||
}
|
||||
|
||||
std::function<const nlohmann::json*(const std::string&)> results_;
|
||||
};
|
||||
|
||||
} // namespace velox::conformance
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The conformance suite. This is the command CI runs on every lane's PR.
|
||||
#
|
||||
# ./tests/conformance/run.sh static + C++ + TS against a mockd it starts
|
||||
# ./tests/conformance/run.sh --uds PATH --ws-port N against an already-running daemon
|
||||
#
|
||||
# Three runners, one set of fixtures:
|
||||
# 1. check_contract.py schemas, fixtures and committed generated code agree
|
||||
# 2. cpp/ the generated C++ parses, serialises and dispatches every fixture
|
||||
# 3. ts/replay.ts a live server answers every fixture over both transports
|
||||
#
|
||||
# Plus one scenario that cannot be shown against a healthy server: with the daemon
|
||||
# answering slower than capture.offer's 750 ms deadline, the client must give up and let
|
||||
# Firefox take the download. That is the fail-open guarantee, and it is checked here.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
HERE="$REPO/tests/conformance"
|
||||
WORK="$(mktemp -d)"
|
||||
EXTERNAL_UDS=""
|
||||
EXTERNAL_WS=""
|
||||
MOCKD_PID=""
|
||||
SLOW_PID=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--uds) EXTERNAL_UDS="$2"; shift 2 ;;
|
||||
--ws-port) EXTERNAL_WS="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) echo "run.sh: unknown option $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Kill the server and anything it spawned. `kill $!` alone would only reap the subshell
|
||||
# wrapper and leave the node process holding the port, which then breaks the next run.
|
||||
stop() {
|
||||
local pid="$1"
|
||||
[ -n "$pid" ] || return 0
|
||||
pkill -P "$pid" 2>/dev/null || true
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
stop "$MOCKD_PID"
|
||||
stop "$SLOW_PID"
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
step() { printf '\n=== %s ===\n' "$1"; }
|
||||
|
||||
# ---------------------------------------------------------------- 1. static
|
||||
step "static conformance (schemas, fixtures, generated code)"
|
||||
python3 "$HERE/check_contract.py"
|
||||
|
||||
# ------------------------------------------------------------------- 2. C++
|
||||
step "generated C++ (parse, serialise, dispatch)"
|
||||
CXX="${CXX:-g++}"
|
||||
"$CXX" -std=c++23 -Wall -Wextra -Wpedantic -Werror \
|
||||
-I"$REPO/core/generated" -I"$HERE/cpp" \
|
||||
"$HERE/cpp/conformance_main.cpp" "$REPO/core/generated/velox_proto.cpp" \
|
||||
-o "$WORK/conformance_cpp"
|
||||
"$WORK/conformance_cpp" "$REPO"
|
||||
|
||||
# -------------------------------------------------------------------- 3. TS
|
||||
step "generated TypeScript against a live server"
|
||||
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
||||
( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund )
|
||||
UDS="$WORK/velox.sock"
|
||||
WS_PORT=52080
|
||||
( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \
|
||||
--uds "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 &
|
||||
MOCKD_PID=$!
|
||||
# Wait for the socket rather than sleeping a guessed amount.
|
||||
for _ in $(seq 1 50); do
|
||||
[ -S "$UDS" ] && node -e "require('net').connect('$UDS').on('connect',function(){this.end();process.exit(0)}).on('error',()=>process.exit(1))" 2>/dev/null && break
|
||||
sleep 0.2
|
||||
done
|
||||
node -e "require('net').connect('$UDS').on('connect',function(){this.end();process.exit(0)}).on('error',()=>process.exit(1))" 2>/dev/null \
|
||||
|| { echo "mockd did not start:"; cat "$WORK/mockd.log"; exit 1; }
|
||||
else
|
||||
UDS="$EXTERNAL_UDS"
|
||||
WS_PORT="$EXTERNAL_WS"
|
||||
fi
|
||||
|
||||
( cd "$HERE/ts" && npm install --silent --no-audit --no-fund )
|
||||
|
||||
TS_ARGS=()
|
||||
[ -n "$UDS" ] && TS_ARGS+=(--uds "$UDS")
|
||||
[ -n "$WS_PORT" ] && TS_ARGS+=(--ws-port "$WS_PORT")
|
||||
( cd "$HERE/ts" && ./node_modules/.bin/tsx replay.ts "${TS_ARGS[@]}" )
|
||||
|
||||
# ------------------------------------------------- 4. capture fails open
|
||||
step "capture.offer fails open when the daemon is too slow"
|
||||
if [ -z "$EXTERNAL_UDS" ]; then
|
||||
SLOW_UDS="$WORK/slow.sock"
|
||||
( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \
|
||||
--uds "$SLOW_UDS" --no-ws --slow 2000 ) >"$WORK/slow.log" 2>&1 &
|
||||
SLOW_PID=$!
|
||||
for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done
|
||||
[ -S "$SLOW_UDS" ] || { echo "slow mockd did not start:"; cat "$WORK/slow.log"; exit 1; }
|
||||
( cd "$HERE/ts" && ./node_modules/.bin/tsx replay.ts --uds "$SLOW_UDS" \
|
||||
--only capture.offer.timeout --include-requires )
|
||||
else
|
||||
echo "skipped: needs a deliberately slow server, which run.sh only arranges for mockd"
|
||||
fi
|
||||
|
||||
printf '\n=== conformance: all runners passed ===\n'
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* A minimal client for each transport, built on the generated types.
|
||||
*
|
||||
* Deliberately not the extension's transport implementation: the conformance suite must
|
||||
* fail when the *contract* is broken, not when the extension's reconnect logic is. It
|
||||
* speaks the two framings and nothing else.
|
||||
*/
|
||||
|
||||
import net from 'node:net';
|
||||
import { WebSocket } from 'ws';
|
||||
import type { MethodName, Params, Result } from '../../../extension/src/shared/protocol/methods.js';
|
||||
|
||||
export type TransportName = 'uds' | 'ws';
|
||||
|
||||
export interface RpcFrame {
|
||||
jsonrpc: '2.0';
|
||||
id?: number | string;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export interface Conn {
|
||||
readonly transport: TransportName;
|
||||
/** Send and wait. Resolves to null when nothing arrives inside `timeoutMs`. */
|
||||
request(method: string, params: unknown, timeoutMs: number): Promise<RpcFrame | null>;
|
||||
/** Typed convenience wrapper, so the suite itself is checked against the contract. */
|
||||
call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>>;
|
||||
notifications(): RpcFrame[];
|
||||
close(): void;
|
||||
}
|
||||
|
||||
abstract class BaseConn implements Conn {
|
||||
abstract readonly transport: TransportName;
|
||||
protected nextId = 1;
|
||||
protected readonly pending = new Map<number | string, (f: RpcFrame) => void>();
|
||||
private readonly received: RpcFrame[] = [];
|
||||
|
||||
protected abstract write(text: string): void;
|
||||
abstract close(): void;
|
||||
|
||||
protected onFrame(frame: RpcFrame): void {
|
||||
if (frame.id !== undefined && this.pending.has(frame.id)) {
|
||||
const resolve = this.pending.get(frame.id);
|
||||
this.pending.delete(frame.id);
|
||||
resolve?.(frame);
|
||||
return;
|
||||
}
|
||||
if (frame.method !== undefined) this.received.push(frame);
|
||||
}
|
||||
|
||||
notifications(): RpcFrame[] {
|
||||
return [...this.received];
|
||||
}
|
||||
|
||||
request(method: string, params: unknown, timeoutMs: number): Promise<RpcFrame | null> {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
resolve(null); // the fail-open case: no answer inside the deadline
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, (frame) => {
|
||||
clearTimeout(timer);
|
||||
resolve(frame);
|
||||
});
|
||||
this.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>> {
|
||||
const frame = await this.request(method, params, 10_000);
|
||||
if (frame === null) throw new Error(`${method}: no response`);
|
||||
if (frame.error) throw new Error(`${method}: error ${frame.error.code}: ${frame.error.message}`);
|
||||
return frame.result as Result<M>;
|
||||
}
|
||||
}
|
||||
|
||||
class UdsConn extends BaseConn {
|
||||
readonly transport = 'uds' as const;
|
||||
private buffer = '';
|
||||
|
||||
constructor(private readonly socket: net.Socket) {
|
||||
super();
|
||||
socket.on('data', (chunk) => {
|
||||
this.buffer += chunk.toString('utf8');
|
||||
let nl = this.buffer.indexOf('\n');
|
||||
while (nl !== -1) {
|
||||
const line = this.buffer.slice(0, nl).trim();
|
||||
this.buffer = this.buffer.slice(nl + 1);
|
||||
nl = this.buffer.indexOf('\n');
|
||||
if (line) this.onFrame(JSON.parse(line) as RpcFrame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected write(text: string): void {
|
||||
this.socket.write(text + '\n');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
class WsConn extends BaseConn {
|
||||
readonly transport = 'ws' as const;
|
||||
|
||||
constructor(private readonly socket: WebSocket) {
|
||||
super();
|
||||
socket.on('message', (data) => this.onFrame(JSON.parse(data.toString()) as RpcFrame));
|
||||
}
|
||||
|
||||
protected write(text: string): void {
|
||||
this.socket.send(text);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectUds(path: string): Promise<Conn> {
|
||||
const socket = net.connect(path);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', () => resolve());
|
||||
socket.once('error', reject);
|
||||
});
|
||||
return new UdsConn(socket);
|
||||
}
|
||||
|
||||
export async function connectWs(port: number): Promise<Conn> {
|
||||
// The daemon verifies this Origin on the upgrade, so the suite must present a real one.
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}`, {
|
||||
headers: { Origin: 'moz-extension://11111111-2222-3333-4444-555555555555' },
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('open', () => resolve());
|
||||
socket.once('error', reject);
|
||||
});
|
||||
return new WsConn(socket);
|
||||
}
|
||||
Generated
+567
@@ -0,0 +1,567 @@
|
||||
{
|
||||
"name": "@velox/conformance",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@velox/conformance",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.13",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@velox/conformance",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Replays contracts/fixtures against a live server through the generated TypeScript client.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"conformance": "tsx replay.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": { "ws": "^8.18.0" },
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Conformance runner, TypeScript side.
|
||||
*
|
||||
* Replays every fixture in contracts/fixtures against a live server — mockd today, veloxd
|
||||
* from M1 — through the generated client types and validators. The same suite runs against
|
||||
* both, which is the point: if a lane drifts from the contract, this goes red the same day
|
||||
* rather than at M2 integration.
|
||||
*
|
||||
* What each fixture asserts
|
||||
* success the reply carries a result; the result passes the generated validator; its
|
||||
* shape matches the golden file
|
||||
* error the reply carries an error with the fixture's code
|
||||
* timeout nothing arrives inside the deadline, and the client is expected to give up.
|
||||
* This is capture.offer's fail-open guarantee, and it is a pass when the
|
||||
* server stays silent.
|
||||
*
|
||||
* Values are compared by *shape*, not by equality: a live daemon returns its own task ids
|
||||
* and its own clock, and demanding byte-identical results would only teach the suite to
|
||||
* lie. Types, key sets and error codes are compared exactly.
|
||||
*
|
||||
* npx tsx replay.ts --uds /run/user/1000/velox/velox.sock --ws-port 52000
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { connectUds, connectWs, type Conn, type TransportName } from './client.js';
|
||||
import {
|
||||
METHODS,
|
||||
isMethodName,
|
||||
type MethodName,
|
||||
} from '../../../extension/src/shared/protocol/methods.js';
|
||||
import { isEventName } from '../../../extension/src/shared/protocol/events.js';
|
||||
import {
|
||||
validateEventParams,
|
||||
validateParams,
|
||||
validateResult,
|
||||
} from '../../../extension/src/shared/protocol/validate.js';
|
||||
|
||||
const HERE = resolve(fileURLToPath(import.meta.url), '..');
|
||||
const REPO = resolve(HERE, '..', '..', '..');
|
||||
const FIXTURES = resolve(REPO, 'contracts', 'fixtures');
|
||||
|
||||
const PLACEHOLDERS = new Set(['$uuid', '$isoDate', '$any', '$opaque', '$taskId', '$taskId2']);
|
||||
|
||||
/**
|
||||
* Concrete stand-ins for the placeholders, used when a golden payload is validated on its
|
||||
* own. A validator applies length and pattern rules, so "$opaque" has to become something
|
||||
* token-shaped before it is checked.
|
||||
*/
|
||||
const CONCRETE: Record<string, string> = {
|
||||
$uuid: 'e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b',
|
||||
$taskId: 'e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b',
|
||||
$taskId2: '11112222-3333-4444-8555-666677778888',
|
||||
$isoDate: '2026-09-09T10:14:52Z',
|
||||
$any: 'placeholder',
|
||||
$opaque: 'cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU',
|
||||
};
|
||||
|
||||
function concrete(value: unknown): unknown {
|
||||
if (typeof value === 'string') return CONCRETE[value] ?? value;
|
||||
if (Array.isArray(value)) return value.map(concrete);
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = concrete(v);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
file: string;
|
||||
name: string;
|
||||
kind?: 'timeout';
|
||||
/** A condition the server cannot produce from the request alone. Skipped unless the
|
||||
* harness has arranged it — see tests/integration. */
|
||||
requires?: string;
|
||||
transport?: TransportName;
|
||||
deadlineMs?: number;
|
||||
request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
|
||||
notification?: { jsonrpc: '2.0'; method: string; params: unknown };
|
||||
response?: { jsonrpc: '2.0'; id: number | string; result?: unknown; error?: { code: number } } | null;
|
||||
}
|
||||
|
||||
interface Outcome {
|
||||
fixture: string;
|
||||
transport: TransportName | 'static';
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shape match
|
||||
|
||||
/**
|
||||
* Compare an actual value against a golden one structurally. Placeholders match anything;
|
||||
* objects must have the same keys; arrays must agree on emptiness and on element shape.
|
||||
*/
|
||||
function shapeMismatch(golden: unknown, actual: unknown, path = ''): string | null {
|
||||
if (typeof golden === 'string' && PLACEHOLDERS.has(golden)) return null;
|
||||
// The generated validator has already ruled on whether null is allowed here, so a null
|
||||
// is never a shape failure: a golden file shows one plausible value, not the only one.
|
||||
if (actual === null) return null;
|
||||
if (golden === null) return actual === null ? null : `${path}: expected null, got ${typeName(actual)}`;
|
||||
if (Array.isArray(golden)) {
|
||||
if (!Array.isArray(actual)) return `${path}: expected an array, got ${typeName(actual)}`;
|
||||
if (golden.length > 0 && actual.length === 0) return `${path}: expected a non-empty array`;
|
||||
if (golden.length > 0 && actual.length > 0) return shapeMismatch(golden[0], actual[0], `${path}/0`);
|
||||
return null;
|
||||
}
|
||||
if (typeof golden === 'object') {
|
||||
if (typeof actual !== 'object' || actual === null || Array.isArray(actual))
|
||||
return `${path}: expected an object, got ${typeName(actual)}`;
|
||||
const g = golden as Record<string, unknown>;
|
||||
const a = actual as Record<string, unknown>;
|
||||
for (const key of Object.keys(g)) {
|
||||
// A golden null means "may be absent"; the contract treats absent and null alike.
|
||||
if (!(key in a)) {
|
||||
if (g[key] === null) continue;
|
||||
return `${path}/${key}: missing from the response`;
|
||||
}
|
||||
const sub = shapeMismatch(g[key], a[key], `${path}/${key}`);
|
||||
if (sub) return sub;
|
||||
}
|
||||
for (const key of Object.keys(a)) {
|
||||
if (!(key in g)) return `${path}/${key}: not in the contract's result`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof golden !== typeof actual) return `${path}: expected ${typeof golden}, got ${typeName(actual)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeName(v: unknown): string {
|
||||
if (v === null) return 'null';
|
||||
if (Array.isArray(v)) return 'array';
|
||||
return typeof v;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- fixtures
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else if (entry.endsWith('.json')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadFixtures(): Fixture[] {
|
||||
return walk(FIXTURES).map((file) => ({
|
||||
...(JSON.parse(readFileSync(file, 'utf8')) as Omit<Fixture, 'file'>),
|
||||
file: relative(REPO, file),
|
||||
}));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- checks
|
||||
|
||||
/** Runs with no server: the generated validators must accept every golden payload. */
|
||||
function staticChecks(fixtures: readonly Fixture[]): Outcome[] {
|
||||
const out: Outcome[] = [];
|
||||
for (const f of fixtures) {
|
||||
if (f.notification) {
|
||||
const name = f.notification.method;
|
||||
if (!isEventName(name)) {
|
||||
out.push({ fixture: f.file, transport: 'static', ok: false, detail: `unknown event ${name}` });
|
||||
continue;
|
||||
}
|
||||
const r = validateEventParams(name, concrete(f.notification.params));
|
||||
out.push({ fixture: f.file, transport: 'static', ok: r.ok,
|
||||
detail: r.ok ? 'event payload validates' : `${r.path}: ${r.message}` });
|
||||
continue;
|
||||
}
|
||||
const method = f.request?.method;
|
||||
if (method === undefined || !isMethodName(method)) continue;
|
||||
|
||||
const expectsInvalidParams = f.response?.error?.code === -32602;
|
||||
const r = validateParams(method, concrete(f.request?.params ?? {}));
|
||||
if (expectsInvalidParams) {
|
||||
out.push({ fixture: f.file, transport: 'static', ok: !r.ok,
|
||||
detail: r.ok ? 'expects -32602 but the params validate' : 'params correctly rejected' });
|
||||
} else {
|
||||
out.push({ fixture: f.file, transport: 'static', ok: r.ok,
|
||||
detail: r.ok ? 'params validate' : `${r.path}: ${r.message}` });
|
||||
}
|
||||
|
||||
if (f.response && 'result' in f.response) {
|
||||
const rr = validateResult(method, concrete(f.response.result));
|
||||
out.push({ fixture: f.file, transport: 'static', ok: rr.ok,
|
||||
detail: rr.ok ? 'golden result validates' : `${rr.path}: ${rr.message}` });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods that destroy the state later fixtures rely on. Replayed last so the suite does
|
||||
* not depend on file order, which is the sort of thing that goes green locally and red in
|
||||
* CI on a different filesystem.
|
||||
*/
|
||||
const DESTRUCTIVE = new Set<string>(['download.remove']);
|
||||
|
||||
function replayOrder(a: Fixture, b: Fixture): number {
|
||||
const rank = (f: Fixture): number => (DESTRUCTIVE.has(f.request?.method ?? '') ? 1 : 0);
|
||||
return rank(a) - rank(b) || a.file.localeCompare(b.file);
|
||||
}
|
||||
|
||||
/** Substitute the ids the runner bound during setup into a fixture's params. */
|
||||
function bind(value: unknown, bindings: Record<string, string>): unknown {
|
||||
if (typeof value === 'string') return bindings[value] ?? value;
|
||||
if (Array.isArray(value)) return value.map((v) => bind(v, bindings));
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[k] = bind(v, bindings);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the tasks the task-referencing fixtures bind to. Doing this per connection is
|
||||
* what lets the same suite run against an empty veloxd and against a seeded mockd.
|
||||
*/
|
||||
async function setupBindings(conn: Conn): Promise<Record<string, string>> {
|
||||
const bindings: Record<string, string> = {};
|
||||
for (const [key, url] of [['$taskId', 'https://example.org/conformance-a.bin'],
|
||||
['$taskId2', 'https://example.org/conformance-b.bin']] as const) {
|
||||
const added = await conn.call('download.add', { url, startMode: 'later' });
|
||||
bindings[key] = added.taskId;
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
async function replay(conn: Conn, fixtures: readonly Fixture[],
|
||||
bindings: Record<string, string>,
|
||||
includeRequires = false): Promise<Outcome[]> {
|
||||
const out: Outcome[] = [];
|
||||
const t = conn.transport;
|
||||
|
||||
for (const f of [...fixtures].sort(replayOrder)) {
|
||||
if (!f.request) continue;
|
||||
const method = f.request.method;
|
||||
if (f.transport !== undefined && f.transport !== t) continue;
|
||||
if (!isMethodName(method)) continue;
|
||||
if (!(METHODS[method].transports as readonly string[]).includes(t)) continue;
|
||||
if (f.requires !== undefined && !includeRequires) {
|
||||
out.push({ fixture: f.file, transport: t, ok: true,
|
||||
detail: `skipped: requires ${f.requires}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const deadline = f.deadlineMs ?? Math.max(METHODS[method].deadlineMs, 2000);
|
||||
const frame = await conn.request(method, bind(f.request.params ?? {}, bindings), deadline);
|
||||
|
||||
if (f.kind === 'timeout') {
|
||||
out.push({
|
||||
fixture: f.file, transport: t, ok: frame === null,
|
||||
detail: frame === null
|
||||
? `no reply within ${deadline} ms — the client fails open, as it must`
|
||||
: 'the server answered a fixture that requires silence',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame === null) {
|
||||
out.push({ fixture: f.file, transport: t, ok: false, detail: `no reply within ${deadline} ms` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const expected = f.response;
|
||||
if (expected && 'error' in expected && expected.error) {
|
||||
const got = frame.error?.code;
|
||||
out.push({
|
||||
fixture: f.file, transport: t, ok: got === expected.error.code,
|
||||
detail: got === expected.error.code
|
||||
? `error ${got} as documented`
|
||||
: `expected error ${expected.error.code}, got ${frame.error ? `error ${got}` : 'a result'}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frame.error) {
|
||||
out.push({ fixture: f.file, transport: t, ok: false,
|
||||
detail: `expected a result, got error ${frame.error.code}: ${frame.error.message}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const validated = validateResult(method, frame.result);
|
||||
if (!validated.ok) {
|
||||
out.push({ fixture: f.file, transport: t, ok: false,
|
||||
detail: `result fails the generated validator at ${validated.path}: ${validated.message}` });
|
||||
continue;
|
||||
}
|
||||
const mismatch = expected && 'result' in expected
|
||||
? shapeMismatch(expected.result, frame.result)
|
||||
: null;
|
||||
out.push({ fixture: f.file, transport: t, ok: mismatch === null,
|
||||
detail: mismatch ?? 'result validates and matches the golden shape' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The transport rules are part of the contract, so they get replayed too. */
|
||||
async function privilegeChecks(conn: Conn): Promise<Outcome[]> {
|
||||
if (conn.transport !== 'ws') return [];
|
||||
const out: Outcome[] = [];
|
||||
const privileged = (Object.keys(METHODS) as MethodName[]).filter((m) => METHODS[m].privileged);
|
||||
for (const method of privileged) {
|
||||
const frame = await conn.request(method, {}, 3000);
|
||||
const ok = frame?.error?.code === -32003;
|
||||
out.push({
|
||||
fixture: `transport-rules/${method}`, transport: 'ws', ok,
|
||||
detail: ok ? 'refused with -32003 over the WebSocket, as required'
|
||||
: `expected -32003, got ${frame ? JSON.stringify(frame.error ?? frame.result).slice(0, 80) : 'no reply'}`,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- main
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (name: string): string | undefined => {
|
||||
const i = argv.indexOf(name);
|
||||
return i === -1 ? undefined : argv[i + 1];
|
||||
};
|
||||
|
||||
// --only narrows the run to fixtures whose path contains a substring, and
|
||||
// --include-requires replays the ones needing a condition the harness has arranged
|
||||
// (a slow daemon, a hostile origin server). run.sh uses both to prove capture.offer
|
||||
// fails open, which cannot be shown against a healthy server.
|
||||
const only = arg('--only');
|
||||
const includeRequires = argv.includes('--include-requires');
|
||||
const all = loadFixtures();
|
||||
const fixtures = only === undefined ? all : all.filter((f) => f.file.includes(only));
|
||||
if (fixtures.length === 0) {
|
||||
process.stderr.write(`conformance: --only ${String(only)} matched no fixtures\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const results: Outcome[] = [...staticChecks(fixtures)];
|
||||
|
||||
const udsPath = arg('--uds');
|
||||
const wsPort = arg('--ws-port');
|
||||
|
||||
if (udsPath) {
|
||||
const conn = await connectUds(udsPath);
|
||||
await conn.call('session.hello', { clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' });
|
||||
results.push(...(await replay(conn, fixtures, await setupBindings(conn), includeRequires)));
|
||||
conn.close();
|
||||
}
|
||||
if (wsPort) {
|
||||
const conn = await connectWs(Number(wsPort));
|
||||
const paired = await conn.request(
|
||||
'session.pair',
|
||||
{ clientName: 'conformance', extensionId: '11111111-2222-3333-4444-555555555555' },
|
||||
5000,
|
||||
);
|
||||
const token = (paired?.result as { token?: string } | undefined)?.token;
|
||||
if (token === undefined) throw new Error('pairing failed: no token issued');
|
||||
await conn.request('session.hello',
|
||||
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000);
|
||||
results.push(...(await replay(conn, fixtures, await setupBindings(conn), includeRequires)));
|
||||
results.push(...(await privilegeChecks(conn)));
|
||||
conn.close();
|
||||
}
|
||||
if (!udsPath && !wsPort) {
|
||||
process.stdout.write('no --uds or --ws-port given: ran static checks only\n');
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
for (const r of failed) {
|
||||
process.stdout.write(`FAIL [${r.transport}] ${r.fixture}\n ${r.detail}\n`);
|
||||
}
|
||||
const byTransport = new Map<string, number>();
|
||||
for (const r of results) byTransport.set(r.transport, (byTransport.get(r.transport) ?? 0) + 1);
|
||||
const summary = [...byTransport].map(([k, v]) => `${k}:${v}`).join(' ');
|
||||
process.stdout.write(`\n${results.length - failed.length}/${results.length} checks passed (${summary})\n`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
process.stderr.write(`conformance: ${String(err)}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.ts", "../../../extension/src/shared/protocol/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user