test: add source-built Hopper conformance fixtures

This commit is contained in:
morluto
2026-07-12 19:32:29 +08:00
parent 4f18e26ee5
commit d648bcd2e4
13 changed files with 465 additions and 0 deletions
+2
View File
@@ -63,6 +63,8 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
"verify:package": "npm run build && node scripts/verify-package.mjs",
"config:print": "node scripts/print-mcp-config.mjs",
"docs:generate": "typedoc",
+223
View File
@@ -0,0 +1,223 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { arch, platform } from "node:os";
import { join, relative, resolve } from "node:path";
import { promisify } from "node:util";
import {
generateLargeFixture,
LARGE_FIXTURE_COUNT,
sha256,
sourceDigest,
} from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const root = process.cwd();
const sourceRoot = join(root, "tests/conformance");
const outputRoot = resolve(
process.env.REA_CONFORMANCE_BUILD ?? join(root, "build/conformance"),
);
if (platform() !== "darwin")
throw new Error(
"Mach-O conformance fixtures require macOS and Xcode command-line tools",
);
await rm(outputRoot, { recursive: true, force: true });
await mkdir(outputRoot, { recursive: true });
const clang = (await exec("xcrun", ["--find", "clang"])).stdout.trim();
const clangVersion = (await exec(clang, ["--version"])).stdout.split("\n")[0];
const sdkPath = (await exec("xcrun", ["--show-sdk-path"])).stdout.trim();
const architecture = arch();
const common = [
"-arch",
architecture,
"-isysroot",
sdkPath,
"-O0",
"-g",
"-fno-inline",
];
const fixtures = [];
await build(
"c",
["c/fixture.c"],
[...common, join(sourceRoot, "c/fixture.c"), "-o", join(outputRoot, "c")],
{
expectations: {
symbols: ["_rea_entry", "_rea_branch", "_rea_leaf", "_rea_c_global"],
strings: ["REA_C_ENTRY", "REA_C_LEAF"],
},
},
);
await build(
"version-v1",
["versions/v1.c"],
[
...common,
join(sourceRoot, "versions/v1.c"),
"-o",
join(outputRoot, "version-v1"),
],
{
expectations: {
symbols: ["_rea_version_entry", "_rea_version_leaf"],
strings: ["REA_VERSION_ONE"],
},
},
);
await build(
"version-v2",
["versions/v2.c"],
[
...common,
join(sourceRoot, "versions/v2.c"),
"-o",
join(outputRoot, "version-v2"),
],
{
expectations: {
symbols: ["_rea_version_entry", "_rea_version_leaf", "_rea_added"],
strings: ["REA_VERSION_TWO"],
},
},
);
const largeSource = generateLargeFixture();
const largePath = join(outputRoot, "large.c");
await writeFile(largePath, largeSource);
await build(
"large",
[{ path: "generated/large.c", content: largeSource }],
[...common, largePath, "-o", join(outputRoot, "large")],
{
expectations: {
symbolPrefix: "_rea_page_",
symbolCount: LARGE_FIXTURE_COUNT,
stringPrefix: "REA_PAGE_",
stringCount: LARGE_FIXTURE_COUNT,
},
},
);
await build(
"objc",
["objc/fixture.m"],
[
...common,
"-fobjc-arc",
join(sourceRoot, "objc/fixture.m"),
"-framework",
"Foundation",
"-o",
join(outputRoot, "objc"),
],
{
expectations: {
symbols: ["_OBJC_CLASS_$_REAWidget"],
strings: ["REAWidget", "REAWidgetDelegate", "performAction:error:"],
},
},
);
await build(
"napi",
["napi/fixture.c"],
[
...common,
"-bundle",
"-undefined",
"dynamic_lookup",
join(sourceRoot, "napi/fixture.c"),
"-o",
join(outputRoot, "fixture.node"),
],
{
expectations: {
symbols: ["_napi_register_module_v1"],
strings: ["reaProbeOne", "reaProbeTwo", "reaProbeThree"],
},
},
);
try {
const swiftc = (await exec("xcrun", ["--find", "swiftc"])).stdout.trim();
const swiftVersion = (await exec(swiftc, ["--version"])).stdout.split(
"\n",
)[0];
await build(
"swift",
["swift/fixture.swift"],
[
"-sdk",
sdkPath,
"-Onone",
"-g",
join(sourceRoot, "swift/fixture.swift"),
"-o",
join(outputRoot, "swift"),
],
{
expectations: {
strings: [
"REA_SWIFT_EXECUTE",
"REAService",
"REARecord",
"REAState",
"REAProtocol",
],
},
compiler: swiftc,
compilerVersion: swiftVersion,
},
);
} catch (cause) {
process.stderr.write(
`Skipping optional Swift fixture: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
}
const manifest = {
schemaVersion: 1,
platform: platform(),
architecture,
generatedAt: new Date(0).toISOString(),
fixtures,
};
await writeFile(
join(outputRoot, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
process.stdout.write(
`${JSON.stringify({ outputRoot, fixtureCount: fixtures.length })}\n`,
);
async function build(name, sources, args, options) {
const compiler = options.compiler ?? clang;
const compilerVersion = options.compilerVersion ?? clangVersion;
const normalized = await Promise.all(
sources.map(async (source) => {
if (typeof source !== "string") return source;
return {
path: source,
content: await readFile(join(sourceRoot, source), "utf8"),
};
}),
);
await exec(compiler, args, { maxBuffer: 10 * 1024 * 1024 });
const artifactPath = args.at(-1);
if (typeof artifactPath !== "string")
throw new Error(`Missing output for ${name}`);
const artifact = await readFile(artifactPath);
fixtures.push({
name,
artifact: relative(outputRoot, artifactPath),
artifactSha256: sha256(artifact),
sourceSha256: sourceDigest(normalized),
sources: normalized.map(({ path, content }) => ({
path,
sha256: sha256(content),
})),
compiler: { path: compiler, version: compilerVersion, arguments: args },
expectations: options.expectations,
});
}
+6
View File
@@ -0,0 +1,6 @@
export const LARGE_FIXTURE_COUNT: number;
export function sha256(value: string | Uint8Array): string;
export function generateLargeFixture(count?: number): string;
export function sourceDigest(
sources: readonly { readonly path: string; readonly content: string }[],
): string;
+30
View File
@@ -0,0 +1,30 @@
import { createHash } from "node:crypto";
export const LARGE_FIXTURE_COUNT = 1_205;
export const sha256 = (value) =>
createHash("sha256").update(value).digest("hex");
export const generateLargeFixture = (count = LARGE_FIXTURE_COUNT) => {
if (!Number.isInteger(count) || count < 1)
throw new Error("Fixture count must be a positive integer");
const declarations = Array.from(
{ length: count },
(_, index) =>
`__attribute__((noinline, used)) int rea_page_${String(index).padStart(4, "0")}(void) { puts("REA_PAGE_${String(index).padStart(4, "0")}"); return ${index}; }`,
);
return [
"#include <stdio.h>",
...declarations,
"int main(void) { return rea_page_0000(); }",
"",
].join("\n");
};
export const sourceDigest = (sources) => {
const canonical = [...sources]
.sort(({ path: left }, { path: right }) => left.localeCompare(right))
.map(({ path, content }) => `${path}\0${sha256(content)}\n`)
.join("");
return sha256(canonical);
};
+68
View File
@@ -0,0 +1,68 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { sha256 } from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const manifestPath = resolve(
process.argv[2] ?? "build/conformance/manifest.json",
);
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.fixtures))
throw new Error("Unsupported conformance manifest");
const root = dirname(manifestPath);
for (const fixture of manifest.fixtures) {
const artifactPath = join(root, fixture.artifact);
const artifact = await readFile(artifactPath);
if (sha256(artifact) !== fixture.artifactSha256)
throw new Error(`${fixture.name}: artifact hash mismatch`);
const fileOutput = (await exec("file", [artifactPath])).stdout;
if (!fileOutput.includes("Mach-O"))
throw new Error(`${fixture.name}: not Mach-O`);
const symbols = (
await exec("nm", ["-gU", artifactPath], { maxBuffer: 20 * 1024 * 1024 })
).stdout;
const strings = (
await exec("strings", [artifactPath], { maxBuffer: 20 * 1024 * 1024 })
).stdout;
for (const symbol of fixture.expectations.symbols ?? [])
if (!symbols.includes(symbol))
throw new Error(`${fixture.name}: missing symbol ${symbol}`);
for (const value of fixture.expectations.strings ?? [])
if (!strings.includes(value))
throw new Error(`${fixture.name}: missing string ${value}`);
verifyCount(
symbols,
fixture.expectations.symbolPrefix,
fixture.expectations.symbolCount,
fixture.name,
);
verifyCount(
strings,
fixture.expectations.stringPrefix,
fixture.expectations.stringCount,
fixture.name,
);
}
const v1 = manifest.fixtures.find(({ name }) => name === "version-v1");
const v2 = manifest.fixtures.find(({ name }) => name === "version-v2");
if (v1?.artifactSha256 === v2?.artifactSha256)
throw new Error("Version-pair artifacts unexpectedly have equal hashes");
process.stdout.write(
`${JSON.stringify({ verified: manifest.fixtures.length, manifestPath })}\n`,
);
function verifyCount(output, prefix, expected, fixture) {
if (prefix === undefined || expected === undefined) return;
const count = output
.split("\n")
.filter((line) => line.includes(prefix)).length;
if (count !== expected)
throw new Error(
`${fixture}: expected ${expected} ${prefix} records, received ${count}`,
);
}
+16
View File
@@ -0,0 +1,16 @@
# Source-built conformance fixtures
These sources are deliberately small semantic oracles for REA providers. Run
`npm run verify:fixtures` on macOS to compile them into the ignored
`build/conformance/` directory, emit a hash-and-toolchain manifest, and verify
their Mach-O symbols and strings with native command-line tools.
Only source files are versioned. Generated binaries, generated large-fixture
source, debug information, and manifests must remain under `build/`.
The C fixture fixes a known call chain and strings. The version pair fixes an
added symbol, changed string, and changed call relationship. The generated
large fixture contains 1,205 uniquely named functions and strings so provider
pagination can be checked at boundaries of 500, 1,000, and 1,205. Objective-C,
Swift, and N-API-like fixtures provide metadata-oriented inputs without package
or network dependencies.
+19
View File
@@ -0,0 +1,19 @@
#include <stdio.h>
volatile int rea_c_global = 7;
__attribute__((noinline, used)) int rea_leaf(int value) {
puts("REA_C_LEAF");
return value + rea_c_global;
}
__attribute__((noinline, used)) int rea_branch(int value) {
return value > 3 ? rea_leaf(value) : rea_leaf(-value);
}
__attribute__((noinline, used)) int rea_entry(void) {
puts("REA_C_ENTRY");
return rea_branch(5);
}
int main(void) { return rea_entry() == 12 ? 0 : 1; }
+17
View File
@@ -0,0 +1,17 @@
typedef void *napi_env;
typedef void *napi_value;
typedef napi_value (*napi_callback)(napi_env, void *);
extern int napi_create_function(napi_env, const char *, unsigned long,
napi_callback, void *, napi_value *);
extern int napi_set_named_property(napi_env, napi_value, const char *, napi_value);
static napi_value rea_probe(napi_env env, void *info) { return (napi_value)info; }
__attribute__((visibility("default"))) napi_value napi_register_module_v1(
napi_env env, napi_value exports) {
const char *names[] = {"reaProbeOne", "reaProbeTwo", "reaProbeThree"};
for (int i = 0; i < 3; i++) {
napi_value function = 0;
napi_create_function(env, names[i], 0, rea_probe, 0, &function);
napi_set_named_property(env, exports, names[i], function);
}
return exports;
}
+21
View File
@@ -0,0 +1,21 @@
#import <Foundation/Foundation.h>
@protocol REAWidgetDelegate <NSObject>
- (void)widgetDidFinish;
@end
@interface REAWidget : NSObject
@property(nonatomic, weak) id<REAWidgetDelegate> delegate;
- (BOOL)performAction:(NSString *)action error:(NSError **)error;
@end
@implementation REAWidget
- (BOOL)performAction:(NSString *)action error:(NSError **)error {
NSLog(@"REA_OBJC_ACTION:%@", action);
return action.length > 0;
}
@end
int main(void) {
@autoreleasepool {
Protocol *protocol = @protocol(REAWidgetDelegate);
NSLog(@"%@", NSStringFromProtocol(protocol));
return [[[REAWidget alloc] init] performAction:@"run" error:0] ? 0 : 1;
}
}
+11
View File
@@ -0,0 +1,11 @@
import Darwin
protocol REAProtocol { func execute() -> Int }
struct REARecord { let value: Int }
enum REAState { case ready, finished }
final class REAService: REAProtocol {
@inline(never) func execute() -> Int { print("REA_SWIFT_EXECUTE"); return 42 }
}
extension REARecord { @inline(never) func doubled() -> Int { value * 2 } }
let service = REAService()
exit(service.execute() == 42 ? 0 : 1)
+9
View File
@@ -0,0 +1,9 @@
#include <stdio.h>
__attribute__((noinline, used)) int rea_version_leaf(void) {
puts("REA_VERSION_ONE");
return 1;
}
__attribute__((noinline, used)) int rea_version_entry(void) {
return rea_version_leaf();
}
int main(void) { return rea_version_entry() == 1 ? 0 : 1; }
+10
View File
@@ -0,0 +1,10 @@
#include <stdio.h>
__attribute__((noinline, used)) int rea_version_leaf(void) {
puts("REA_VERSION_TWO");
return 2;
}
__attribute__((noinline, used)) int rea_added(void) { return 40; }
__attribute__((noinline, used)) int rea_version_entry(void) {
return rea_version_leaf() + rea_added();
}
int main(void) { return rea_version_entry() == 42 ? 0 : 1; }
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
generateLargeFixture,
LARGE_FIXTURE_COUNT,
sha256,
sourceDigest,
} from "../scripts/lib/conformance-fixtures.mjs";
describe("source-built conformance fixtures", () => {
it("generates a deterministic large pagination fixture", () => {
const first = generateLargeFixture();
const second = generateLargeFixture();
expect(first).toBe(second);
expect(first.match(/int rea_page_/gu)).toHaveLength(LARGE_FIXTURE_COUNT);
expect(first).toContain("rea_page_0000");
expect(first).toContain("rea_page_1204");
expect(() => generateLargeFixture(0)).toThrow(/positive integer/u);
});
it("hashes source manifests independently of input ordering", () => {
const left = [
{ path: "b.c", content: "b" },
{ path: "a.c", content: "a" },
];
const right = [...left].reverse();
expect(sourceDigest(left)).toBe(sourceDigest(right));
expect(sourceDigest(left)).toMatch(/^[0-9a-f]{64}$/u);
expect(sha256("fixture")).not.toBe(sha256("changed"));
});
});