Skip to content

Commit 3bdfd27

Browse files
authored
Add test suite for wire format, schema, and codegen (#14)
Adds a zero-dependency test suite covering the parts of the toolchain that previously had no automated coverage: the binary wire format, schema parsing, and code generation. Runs in CI between building the toolchain and the examples.
1 parent d7c32ef commit 3bdfd27

14 files changed

Lines changed: 1770 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ jobs:
2121
- name: Build Toolchain
2222
run: ./build.sh
2323

24+
- name: Run Tests
25+
run: ./tests/run.sh
26+
2427
- name: Build Examples
2528
run: |
2629
EXAMPLES=(

tests/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
build/

tests/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# WebCC Tests
2+
3+
Zero-dependency test suite covering the binary wire format, schema parsing, and
4+
code generation.
5+
6+
```sh
7+
./tests/run.sh # build & run everything
8+
./tests/run.sh --update # regenerate golden snapshots, then run
9+
./tests/run.sh --skip-js # C++ tests only (no webcc build / node needed)
10+
```
11+
12+
Exit code is non-zero on any failure, so CI fails loudly. The suite runs in CI
13+
(`.github/workflows/ci.yml`) between building the toolchain and the examples.
14+
15+
## Layers
16+
17+
**C++ unit tests** (host-native, linked against the real toolchain sources):
18+
19+
| File | What it covers |
20+
| --- | --- |
21+
| [test_command_buffer.cc](test_command_buffer.cc) | The C++/JS wire format: little-endian ints, IEEE-754 floats/doubles, 8-byte double alignment, 4-byte string padding. This is the contract the generated JS decoder walks. |
22+
| [test_schema.cc](test_schema.cc) | `load_defs` parsing: opcode assignment, `handle(T)` extraction, `RET:` handling, inheritance, pipes inside JS actions, plus the `schema.wcc.bin` binary-cache round-trip. |
23+
| [test_codegen.cc](test_codegen.cc) | Golden snapshots of `emit_headers` and `generate_js_runtime` output, plus tree-shaking assertions (a canvas-only build embeds canvas code and not DOM/WebSocket/WebGPU). |
24+
25+
**JS validation** ([js/check_js.mjs](js/check_js.mjs)): generates `app.js` for
26+
several feature combinations using the real `webcc` binary and parses each with
27+
Node's `vm.Script`, confirming the generated JavaScript is syntactically valid.
28+
29+
## Snapshots
30+
31+
Golden files live in [snapshots/](snapshots/). When you change codegen, run
32+
`./tests/run.sh --update` and review the diff before committing. The diff is the
33+
review of your generator change.
34+
35+
## Adding a test
36+
37+
Add a `TEST(name) { ... }` block (see [framework.h](framework.h)) to any
38+
`test_*.cc`, then list the file in the compile line in [run.sh](run.sh). Tests
39+
self-register; no manual wiring.
40+
41+
## Possible next addition
42+
43+
The JS layer syntax-checks `app.js`. A full execution round-trip (C++ encodes
44+
commands, the generated JS decoder runs, assert the decoded DOM/canvas calls)
45+
would extend coverage to the runtime behavior end to end.

tests/framework.h

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Tiny zero-dependency test framework for WebCC.
2+
//
3+
// Usage:
4+
// TEST(my_test_name) {
5+
// CHECK(some_condition);
6+
// CHECK_EQ(actual, expected);
7+
// }
8+
//
9+
// Tests self-register at static-init time. The `tests` binary (test_main.cc)
10+
// calls webcc_test::run_all() to execute them all and returns non-zero on any
11+
// failure. No macros leak a main(); link as many test_*.cc files as you like.
12+
#pragma once
13+
14+
#include <cstdint>
15+
#include <functional>
16+
#include <iostream>
17+
#include <sstream>
18+
#include <string>
19+
#include <vector>
20+
21+
namespace webcc_test
22+
{
23+
struct TestCase
24+
{
25+
std::string name;
26+
std::function<void()> fn;
27+
};
28+
29+
// Per-run state. Failures are appended here by the CHECK macros.
30+
struct Context
31+
{
32+
std::vector<std::string> failures;
33+
std::string current_test;
34+
};
35+
36+
inline std::vector<TestCase> &registry()
37+
{
38+
static std::vector<TestCase> r;
39+
return r;
40+
}
41+
42+
inline Context &ctx()
43+
{
44+
static Context c;
45+
return c;
46+
}
47+
48+
inline int register_test(const std::string &name, std::function<void()> fn)
49+
{
50+
registry().push_back({name, std::move(fn)});
51+
return 0;
52+
}
53+
54+
inline void record_failure(const std::string &msg)
55+
{
56+
ctx().failures.push_back(ctx().current_test + ": " + msg);
57+
}
58+
59+
inline int run_all()
60+
{
61+
int passed = 0;
62+
int failed = 0;
63+
for (auto &tc : registry())
64+
{
65+
ctx().current_test = tc.name;
66+
size_t before = ctx().failures.size();
67+
try
68+
{
69+
tc.fn();
70+
}
71+
catch (const std::exception &e)
72+
{
73+
record_failure(std::string("threw exception: ") + e.what());
74+
}
75+
catch (...)
76+
{
77+
record_failure("threw unknown exception");
78+
}
79+
bool ok = ctx().failures.size() == before;
80+
std::cout << (ok ? " PASS " : " FAIL ") << tc.name << "\n";
81+
ok ? ++passed : ++failed;
82+
}
83+
84+
std::cout << "\n";
85+
if (failed)
86+
{
87+
std::cout << "Failures:\n";
88+
for (const auto &f : ctx().failures)
89+
std::cout << " - " << f << "\n";
90+
std::cout << "\n";
91+
}
92+
std::cout << passed << " passed, " << failed << " failed, "
93+
<< registry().size() << " total.\n";
94+
return failed == 0 ? 0 : 1;
95+
}
96+
97+
// Stringify helper used by CHECK_EQ.
98+
template <typename T>
99+
std::string to_str(const T &v)
100+
{
101+
std::ostringstream ss;
102+
ss << v;
103+
return ss.str();
104+
}
105+
} // namespace webcc_test
106+
107+
#define WEBCC_CAT_(a, b) a##b
108+
#define WEBCC_CAT(a, b) WEBCC_CAT_(a, b)
109+
110+
#define TEST(name) \
111+
static void name(); \
112+
static int WEBCC_CAT(name, _reg) = \
113+
::webcc_test::register_test(#name, name); \
114+
static void name()
115+
116+
#define CHECK(cond) \
117+
do \
118+
{ \
119+
if (!(cond)) \
120+
{ \
121+
::webcc_test::record_failure( \
122+
std::string("CHECK failed: " #cond " (") + __FILE__ + ":" + \
123+
std::to_string(__LINE__) + ")"); \
124+
} \
125+
} while (0)
126+
127+
#define CHECK_EQ(actual, expected) \
128+
do \
129+
{ \
130+
auto _a = (actual); \
131+
auto _e = (expected); \
132+
if (!(_a == _e)) \
133+
{ \
134+
::webcc_test::record_failure( \
135+
std::string("CHECK_EQ failed: " #actual " == " #expected \
136+
" (got '") + \
137+
::webcc_test::to_str(_a) + "', expected '" + \
138+
::webcc_test::to_str(_e) + "') (" + __FILE__ + ":" + \
139+
std::to_string(__LINE__) + ")"); \
140+
} \
141+
} while (0)

tests/js/check_js.mjs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Validates that generated app.js is syntactically valid JavaScript.
2+
//
3+
// app.js is assembled from string templates and schema-derived action snippets
4+
// in generators.cc. This uses the webcc binary to generate app.js for a range of
5+
// feature combinations and syntax-checks each with `new vm.Script`, so a
6+
// malformed JS action is caught in CI rather than at runtime in the browser.
7+
//
8+
// Usage: node tests/js/check_js.mjs <path-to-webcc-binary> <repo-root>
9+
import { execFileSync } from "node:child_process";
10+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
11+
import { tmpdir } from "node:os";
12+
import { join } from "node:path";
13+
import vm from "node:vm";
14+
15+
const [, , webccBin, repoRoot] = process.argv;
16+
if (!webccBin || !repoRoot) {
17+
console.error("usage: node check_js.mjs <webcc-binary> <repo-root>");
18+
process.exit(2);
19+
}
20+
21+
// Feature programs chosen to exercise different generator paths:
22+
// void commands, return-value imports, event delegation, string args, floats.
23+
const cases = {
24+
canvas: `#include "webcc/canvas.h"
25+
int main(){ auto c=webcc::canvas::create_canvas("c",640,480);
26+
auto x=webcc::canvas::get_context_2d(c);
27+
webcc::canvas::fill_rect(x,0,0,100,100);
28+
webcc::canvas::fill_text(x,"hi",10,10); }`,
29+
30+
dom_events: `#include "webcc/dom.h"
31+
int main(){ auto b=webcc::dom::get_body();
32+
auto i=webcc::dom::create_element("input");
33+
webcc::dom::add_input_listener(i);
34+
webcc::dom::add_click_listener(i);
35+
webcc::dom::append_child(b,i); }`,
36+
37+
webgl: `#include "webcc/webgl.h"
38+
#include "webcc/canvas.h"
39+
int main(){ auto c=webcc::canvas::create_canvas("c",640,480);
40+
auto gl=webcc::canvas::get_context_webgl(c);
41+
webcc::webgl::clear_color(gl,0,0,0,1);
42+
webcc::webgl::clear(gl,16384); }`,
43+
44+
websocket: `#include "webcc/websocket.h"
45+
int main(){ auto ws=webcc::websocket::connect("wss://x");
46+
webcc::websocket::send(ws,"hello"); }`,
47+
48+
fetch_storage: `#include "webcc/fetch.h"
49+
#include "webcc/storage.h"
50+
int main(){ webcc::fetch::get("/api","{}");
51+
webcc::storage::set_item("k","v"); }`,
52+
53+
webgpu: `#include "webcc/wgpu.h"
54+
int main(){ webcc::wgpu::request_adapter(); }`,
55+
};
56+
57+
const work = mkdtempSync(join(tmpdir(), "webcc-js-"));
58+
let failures = 0;
59+
60+
for (const [name, src] of Object.entries(cases)) {
61+
const srcPath = join(work, `${name}.cc`);
62+
const outDir = join(work, name);
63+
writeFileSync(srcPath, src);
64+
execFileSync("mkdir", ["-p", outDir]);
65+
66+
// We only need JS generation, which happens before WASM compilation. We let
67+
// webcc run fully; if the toolchain can compile it too, great, but a compile
68+
// failure shouldn't mask a JS syntax check. So generate, then read app.js if
69+
// present.
70+
try {
71+
execFileSync(webccBin, ["-o", outDir, srcPath], {
72+
cwd: repoRoot,
73+
stdio: "pipe",
74+
});
75+
} catch (e) {
76+
// Compilation may fail in minimal CI (missing wasm libc bits) but app.js is
77+
// written before compilation. Only treat as fatal if app.js is absent.
78+
}
79+
80+
let js;
81+
try {
82+
js = readFileSync(join(outDir, "app.js"), "utf8");
83+
} catch {
84+
console.error(` FAIL ${name}: app.js was not generated`);
85+
failures++;
86+
continue;
87+
}
88+
89+
try {
90+
// Parse-only: constructing a Script compiles (syntax-checks) without running.
91+
new vm.Script(js, { filename: `${name}/app.js` });
92+
console.log(` PASS ${name} (app.js parses, ${js.length} bytes)`);
93+
} catch (e) {
94+
console.error(` FAIL ${name}: ${e.message}`);
95+
failures++;
96+
}
97+
}
98+
99+
rmSync(work, { recursive: true, force: true });
100+
101+
console.log("");
102+
if (failures) {
103+
console.error(`${failures} JS validation failure(s).`);
104+
process.exit(1);
105+
}
106+
console.log("All generated app.js files are valid JavaScript.");

tests/run.sh

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/bin/bash
2+
# WebCC test runner.
3+
#
4+
# ./tests/run.sh Build & run all tests (C++ units + codegen snapshots + JS validation)
5+
# ./tests/run.sh --update Regenerate golden snapshots, then run
6+
# ./tests/run.sh --skip-js Skip the Node JS-validation layer (no webcc build / node needed)
7+
#
8+
# Zero extra dependencies: uses the same clang++ the toolchain requires, plus
9+
# node (already needed to serve examples) for the JS syntax checks.
10+
set -e
11+
12+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
13+
BUILD="$ROOT/tests/build"
14+
mkdir -p "$BUILD"
15+
16+
UPDATE=0
17+
SKIP_JS=0
18+
for arg in "$@"; do
19+
case "$arg" in
20+
--update) UPDATE=1 ;;
21+
--skip-js) SKIP_JS=1 ;;
22+
*) echo "Unknown option: $arg"; exit 2 ;;
23+
esac
24+
done
25+
26+
CXX="${CXX:-clang++}"
27+
28+
echo "[tests] Compiling C++ test suite..."
29+
"$CXX" -std=c++20 -O1 -g \
30+
-I "$ROOT/src/cli" -I "$ROOT/src/core" \
31+
-DWEBCC_SCHEMA_DEF="\"$ROOT/schema.def\"" \
32+
-DWEBCC_SNAPSHOT_DIR="\"$ROOT/tests/snapshots\"" \
33+
"$ROOT/tests/test_main.cc" \
34+
"$ROOT/tests/test_command_buffer.cc" \
35+
"$ROOT/tests/test_schema.cc" \
36+
"$ROOT/tests/test_codegen.cc" \
37+
"$ROOT/src/cli/schema.cc" \
38+
"$ROOT/src/cli/utils.cc" \
39+
"$ROOT/src/cli/generators.cc" \
40+
"$ROOT/src/core/command_buffer.cc" \
41+
-o "$BUILD/tests"
42+
43+
echo "[tests] Running C++ test suite..."
44+
if [ "$UPDATE" = "1" ]; then
45+
WEBCC_UPDATE_SNAPSHOTS=1 "$BUILD/tests"
46+
echo "[tests] Snapshots updated. Re-running to verify..."
47+
fi
48+
"$BUILD/tests"
49+
CPP_STATUS=$?
50+
51+
if [ "$SKIP_JS" = "1" ]; then
52+
echo "[tests] Skipping JS validation (--skip-js)."
53+
exit $CPP_STATUS
54+
fi
55+
56+
if ! command -v node >/dev/null 2>&1; then
57+
echo "[tests] node not found; skipping JS validation layer."
58+
exit $CPP_STATUS
59+
fi
60+
61+
# Ensure the webcc binary exists for JS generation.
62+
if [ ! -x "$ROOT/webcc" ]; then
63+
echo "[tests] webcc binary not found; building it (ninja)..."
64+
( cd "$ROOT" && ninja )
65+
fi
66+
67+
echo "[tests] Validating generated JavaScript..."
68+
node "$ROOT/tests/js/check_js.mjs" "$ROOT/webcc" "$ROOT"

0 commit comments

Comments
 (0)