Export live plugin sidecar witnesses
This commit is contained in:
parent
319b2917e9
commit
86dbb685f8
8 changed files with 312 additions and 2 deletions
|
|
@ -287,8 +287,8 @@ Exit criteria:
|
||||||
|
|
||||||
1. Graduate the generic Electron host/preload contract into EveElectron so
|
1. Graduate the generic Electron host/preload contract into EveElectron so
|
||||||
Aetheria retains only daemon lifecycle and provider transport configuration.
|
Aetheria retains only daemon lifecycle and provider transport configuration.
|
||||||
2. Replace incubating Sai/Norn projection declarations with one live sidecar
|
2. Build Sai's owner-side playback daemon and live sidecar witness without
|
||||||
deployment witness per plugin, preserving their independent ownership.
|
absorbing Norn or TeX semantics into the Sai process.
|
||||||
|
|
||||||
Recently cut:
|
Recently cut:
|
||||||
|
|
||||||
|
|
@ -353,6 +353,16 @@ Recently cut:
|
||||||
renderer bundle, while the live Electron witness still proves the resolved
|
renderer bundle, while the live Electron witness still proves the resolved
|
||||||
field surface. Eve core documents this as the host/plugin decoding boundary,
|
field surface. Eve core documents this as the host/plugin decoding boundary,
|
||||||
not as an Aetheria-specific exception.
|
not as an Aetheria-specific exception.
|
||||||
|
- Norn now has live runtime-independent sidecar proof, not only projection
|
||||||
|
declarations. The owner-repo `norn-eve-plugin` stays alive across correlated
|
||||||
|
`describe`, `validate`, `project`, and solver-backed `measure` requests over
|
||||||
|
advertised `stdio-ndjson`; all four operations satisfy Norn's ABI fixture.
|
||||||
|
Eve's generic sidecar witness launcher validates advertisement/fixture
|
||||||
|
agreement and response correlation, emits `gamecult.eve.plugin_witness.v1`,
|
||||||
|
and the conformance attachment path exports that witness without Norn source
|
||||||
|
paths. The first live run also removed a UTF-8 BOM framing fault at Norn's
|
||||||
|
NDJSON boundary. This proves Norn independently; it does not count as Sai
|
||||||
|
playback proof.
|
||||||
|
|
||||||
- The clean EveUnity PlayMode client now consumes Aetheria's independently
|
- The clean EveUnity PlayMode client now consumes Aetheria's independently
|
||||||
running daemon without importing Aetheria code. Aetheria builds its authored
|
running daemon without importing Aetheria code. Aetheria builds its authored
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,23 @@
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"pluginWitnesses": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["witnessId", "pluginId", "ownerRepo", "status", "transport", "operationCount", "witnessExportPath"],
|
||||||
|
"properties": {
|
||||||
|
"witnessId": { "type": "string", "minLength": 1 },
|
||||||
|
"pluginId": { "type": "string", "minLength": 1 },
|
||||||
|
"ownerRepo": { "type": "string", "minLength": 1 },
|
||||||
|
"status": { "enum": ["pass", "fail"] },
|
||||||
|
"transport": { "type": "string", "minLength": 1 },
|
||||||
|
"operationCount": { "type": "integer", "minimum": 1 },
|
||||||
|
"witnessExportPath": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"packs": {
|
"packs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|
|
||||||
35
schemas/gamecult.eve.plugin_witness.v1.schema.json
Normal file
35
schemas/gamecult.eve.plugin_witness.v1.schema.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "gamecult.eve.plugin_witness.v1",
|
||||||
|
"title": "Eve Plugin Sidecar Witness",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["schema", "witnessId", "pluginId", "ownerRepo", "status", "transport", "generatedAtUtc", "durationMs", "operations", "authority"],
|
||||||
|
"properties": {
|
||||||
|
"schema": { "const": "gamecult.eve.plugin_witness.v1" },
|
||||||
|
"witnessId": { "type": "string", "minLength": 1 },
|
||||||
|
"pluginId": { "type": "string", "minLength": 1 },
|
||||||
|
"ownerRepo": { "type": "string", "minLength": 1 },
|
||||||
|
"status": { "enum": ["pass", "fail"] },
|
||||||
|
"transport": { "type": "string", "minLength": 1 },
|
||||||
|
"generatedAtUtc": { "type": "string", "minLength": 1 },
|
||||||
|
"durationMs": { "type": "number", "minimum": 0 },
|
||||||
|
"operations": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["operation", "requestId", "status", "durationMs", "expectationCount"],
|
||||||
|
"properties": {
|
||||||
|
"operation": { "type": "string", "minLength": 1 },
|
||||||
|
"requestId": { "type": "string", "minLength": 1 },
|
||||||
|
"status": { "type": "string", "minLength": 1 },
|
||||||
|
"durationMs": { "type": "number", "minimum": 0 },
|
||||||
|
"expectationCount": { "type": "integer", "minimum": 0 }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"authority": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
44
tools/conformance/attach-plugin-witness.mjs
Normal file
44
tools/conformance/attach-plugin-witness.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { validateSchemaSubset } from "../../runtimes/incubating/test-support/schema-subset.mjs";
|
||||||
|
|
||||||
|
const [witnessArgument, exportArgument] = process.argv.slice(2);
|
||||||
|
if (!witnessArgument || !exportArgument) {
|
||||||
|
console.error("Usage: node tools/conformance/attach-plugin-witness.mjs <plugin-witness.json> <conformance-export-directory>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const witnessPath = path.resolve(witnessArgument);
|
||||||
|
const exportDirectory = path.resolve(exportArgument);
|
||||||
|
const indexPath = path.join(exportDirectory, "index.json");
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const witness = parseJson(await readFile(witnessPath, "utf8"));
|
||||||
|
const schema = parseJson(await readFile(path.join(repoRoot, "schemas/gamecult.eve.plugin_witness.v1.schema.json"), "utf8"));
|
||||||
|
const schemaErrors = validateSchemaSubset(schema, witness);
|
||||||
|
if (schemaErrors.length) throw new Error(`Plugin witness schema errors: ${schemaErrors.join("; ")}`);
|
||||||
|
|
||||||
|
const attachmentRoot = path.join(exportDirectory, "attachments", "plugin", safeSegment(witness.witnessId));
|
||||||
|
await mkdir(attachmentRoot, { recursive: true });
|
||||||
|
const exportedWitnessPath = path.join(attachmentRoot, "witness.json");
|
||||||
|
await writeFile(exportedWitnessPath, `${JSON.stringify(witness, null, 2)}\n`);
|
||||||
|
|
||||||
|
const index = parseJson(await readFile(indexPath, "utf8"));
|
||||||
|
const record = {
|
||||||
|
witnessId: witness.witnessId,
|
||||||
|
pluginId: witness.pluginId,
|
||||||
|
ownerRepo: witness.ownerRepo,
|
||||||
|
status: witness.status,
|
||||||
|
transport: witness.transport,
|
||||||
|
operationCount: witness.operations.length,
|
||||||
|
witnessExportPath: slash(path.relative(exportDirectory, exportedWitnessPath)),
|
||||||
|
};
|
||||||
|
index.pluginWitnesses = [
|
||||||
|
...(Array.isArray(index.pluginWitnesses) ? index.pluginWitnesses : []).filter(candidate => candidate.witnessId !== record.witnessId),
|
||||||
|
record,
|
||||||
|
].sort((left, right) => left.witnessId.localeCompare(right.witnessId));
|
||||||
|
await writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`);
|
||||||
|
console.log(exportedWitnessPath);
|
||||||
|
|
||||||
|
function safeSegment(value) { return String(value).replace(/[^a-zA-Z0-9._-]+/g, "-"); }
|
||||||
|
function slash(value) { return value.replaceAll("\\", "/"); }
|
||||||
|
function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); }
|
||||||
|
|
@ -40,6 +40,7 @@ if (!exportDirectory) {
|
||||||
" --expect-plugin-abi-field <pluginId:operation:field.path:value>",
|
" --expect-plugin-abi-field <pluginId:operation:field.path:value>",
|
||||||
" --expect-plugin-abi-operation-coverage <pluginId:operation:status:ownerRepo>",
|
" --expect-plugin-abi-operation-coverage <pluginId:operation:status:ownerRepo>",
|
||||||
" --expect-plugin-handoff <pluginId>",
|
" --expect-plugin-handoff <pluginId>",
|
||||||
|
" --expect-plugin-witness <pluginId:transport:status>",
|
||||||
" --expect-plugin-handoff-move <pluginId:moveSetId:pathKind:status:path-substring>",
|
" --expect-plugin-handoff-move <pluginId:moveSetId:pathKind:status:path-substring>",
|
||||||
" --expect-provider-handoff-move <providerId:moveSetId:pathKind:status:path-substring>",
|
" --expect-provider-handoff-move <providerId:moveSetId:pathKind:status:path-substring>",
|
||||||
" --expect-provider-surface <providerId:surfaceId>",
|
" --expect-provider-surface <providerId:surfaceId>",
|
||||||
|
|
@ -140,6 +141,7 @@ function validateIndex(index, directory, expectations, errors) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const plugins = Array.isArray(index.plugins) ? index.plugins : [];
|
const plugins = Array.isArray(index.plugins) ? index.plugins : [];
|
||||||
|
const pluginWitnesses = Array.isArray(index.pluginWitnesses) ? index.pluginWitnesses : [];
|
||||||
const providers = Array.isArray(index.providers) ? index.providers : [];
|
const providers = Array.isArray(index.providers) ? index.providers : [];
|
||||||
const runtimes = mergeRuntimeRecords(Array.isArray(index.runtimes) ? index.runtimes : [], exportedRuntimeTargets);
|
const runtimes = mergeRuntimeRecords(Array.isArray(index.runtimes) ? index.runtimes : [], exportedRuntimeTargets);
|
||||||
const splitTargets = Array.isArray(index.splitTargets) ? index.splitTargets : [];
|
const splitTargets = Array.isArray(index.splitTargets) ? index.splitTargets : [];
|
||||||
|
|
@ -163,6 +165,7 @@ function validateIndex(index, directory, expectations, errors) {
|
||||||
const worldSurfaceLoweringGaps = Array.isArray(index.worldSurfaceLoweringGaps) ? index.worldSurfaceLoweringGaps : [];
|
const worldSurfaceLoweringGaps = Array.isArray(index.worldSurfaceLoweringGaps) ? index.worldSurfaceLoweringGaps : [];
|
||||||
|
|
||||||
validatePluginRecords(plugins, errors);
|
validatePluginRecords(plugins, errors);
|
||||||
|
validatePluginWitnesses(pluginWitnesses, directory, errors);
|
||||||
validatePluginAbiOperationCoverage(index.pluginAbiOperationCoverage, errors);
|
validatePluginAbiOperationCoverage(index.pluginAbiOperationCoverage, errors);
|
||||||
validateProviderRecords(providers, errors);
|
validateProviderRecords(providers, errors);
|
||||||
validateProviderPluginRequirementCoverage(index.providerPluginRequirementCoverage, errors);
|
validateProviderPluginRequirementCoverage(index.providerPluginRequirementCoverage, errors);
|
||||||
|
|
@ -228,6 +231,15 @@ function validateIndex(index, directory, expectations, errors) {
|
||||||
errors.push(`runtimeWitnesses:${witness.witnessId}:status:expected ${expectation.status} got ${witness.status || ""}`);
|
errors.push(`runtimeWitnesses:${witness.witnessId}:status:expected ${expectation.status} got ${witness.status || ""}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const expectation of expectations.pluginWitnesses) {
|
||||||
|
const witness = pluginWitnesses.find(candidate =>
|
||||||
|
candidate.pluginId === expectation.pluginId && candidate.transport === expectation.transport);
|
||||||
|
if (!witness) {
|
||||||
|
errors.push(`pluginWitnesses:${expectation.pluginId}:${expectation.transport}:missing`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (witness.status !== expectation.status) errors.push(`pluginWitnesses:${expectation.pluginId}:status:expected ${expectation.status} got ${witness.status || ""}`);
|
||||||
|
}
|
||||||
for (const expectation of expectations.providerRuntimePluginProjectionCoverage) {
|
for (const expectation of expectations.providerRuntimePluginProjectionCoverage) {
|
||||||
const record = providerRuntimePluginProjectionCoverage.find(candidate =>
|
const record = providerRuntimePluginProjectionCoverage.find(candidate =>
|
||||||
candidate.providerId === expectation.providerId &&
|
candidate.providerId === expectation.providerId &&
|
||||||
|
|
@ -1168,6 +1180,23 @@ function validatePluginRecords(plugins, errors) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePluginWitnesses(witnesses, directory, errors) {
|
||||||
|
if (!Array.isArray(witnesses)) return;
|
||||||
|
for (const [index, witness] of witnesses.entries()) {
|
||||||
|
for (const field of ["witnessId", "pluginId", "ownerRepo", "status", "transport", "operationCount", "witnessExportPath"]) {
|
||||||
|
if (witness?.[field] === undefined || witness?.[field] === "") errors.push(`pluginWitnesses:${index}:${field}:missing`);
|
||||||
|
}
|
||||||
|
const witnessPath = witness?.witnessExportPath ? path.join(directory, witness.witnessExportPath) : "";
|
||||||
|
if (!witnessPath || !existsSync(witnessPath)) {
|
||||||
|
errors.push(`pluginWitnesses:${index}:witnessExportPath:missing`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const document = JSON.parse(readFileSyncUtf8(witnessPath));
|
||||||
|
if (document.schema !== "gamecult.eve.plugin_witness.v1") errors.push(`pluginWitnesses:${index}:schema:unexpected`);
|
||||||
|
if ((document.operations || []).length !== witness.operationCount) errors.push(`pluginWitnesses:${index}:operationCount:mismatch`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validatePluginAbiOperationCoverage(records, errors) {
|
function validatePluginAbiOperationCoverage(records, errors) {
|
||||||
if (!Array.isArray(records)) {
|
if (!Array.isArray(records)) {
|
||||||
errors.push("pluginAbiOperationCoverage:missing");
|
errors.push("pluginAbiOperationCoverage:missing");
|
||||||
|
|
@ -1343,6 +1372,7 @@ function parseArguments(args) {
|
||||||
pluginAbiFields: [],
|
pluginAbiFields: [],
|
||||||
pluginAbiOperationCoverage: [],
|
pluginAbiOperationCoverage: [],
|
||||||
pluginHandoffs: [],
|
pluginHandoffs: [],
|
||||||
|
pluginWitnesses: [],
|
||||||
pluginHandoffMoves: [],
|
pluginHandoffMoves: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
providerSurfaces: [],
|
providerSurfaces: [],
|
||||||
|
|
@ -1408,6 +1438,7 @@ function parseArguments(args) {
|
||||||
["--expect-plugin-abi-field", expectations.pluginAbiFields],
|
["--expect-plugin-abi-field", expectations.pluginAbiFields],
|
||||||
["--expect-plugin-abi-operation-coverage", expectations.pluginAbiOperationCoverage],
|
["--expect-plugin-abi-operation-coverage", expectations.pluginAbiOperationCoverage],
|
||||||
["--expect-plugin-handoff", expectations.pluginHandoffs],
|
["--expect-plugin-handoff", expectations.pluginHandoffs],
|
||||||
|
["--expect-plugin-witness", expectations.pluginWitnesses],
|
||||||
["--expect-plugin-handoff-move", expectations.pluginHandoffMoves],
|
["--expect-plugin-handoff-move", expectations.pluginHandoffMoves],
|
||||||
["--expect-provider-surface", expectations.providerSurfaces],
|
["--expect-provider-surface", expectations.providerSurfaces],
|
||||||
["--expect-provider-surface-kind", expectations.providerSurfaceKinds],
|
["--expect-provider-surface-kind", expectations.providerSurfaceKinds],
|
||||||
|
|
@ -1488,6 +1519,8 @@ function parseArguments(args) {
|
||||||
target.push(parsePluginAbiOperationCoverageExpectation(value));
|
target.push(parsePluginAbiOperationCoverageExpectation(value));
|
||||||
} else if (option === "--expect-plugin-handoff") {
|
} else if (option === "--expect-plugin-handoff") {
|
||||||
target.push(value);
|
target.push(value);
|
||||||
|
} else if (option === "--expect-plugin-witness") {
|
||||||
|
target.push(parsePluginWitnessExpectation(value));
|
||||||
} else if (option === "--expect-plugin-handoff-move") {
|
} else if (option === "--expect-plugin-handoff-move") {
|
||||||
target.push(parsePluginHandoffMoveExpectation(value));
|
target.push(parsePluginHandoffMoveExpectation(value));
|
||||||
} else if (option === "--expect-provider-surface") {
|
} else if (option === "--expect-provider-surface") {
|
||||||
|
|
@ -1928,6 +1961,15 @@ function parseProviderExpectation(value, field) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePluginWitnessExpectation(value) {
|
||||||
|
const parts = value.split(":");
|
||||||
|
if (parts.length !== 3 || parts.some(part => !part)) {
|
||||||
|
console.error(`Expected plugin witness in <pluginId:transport:status> form, got: ${value}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
return { pluginId: parts[0], transport: parts[1], status: parts[2] };
|
||||||
|
}
|
||||||
|
|
||||||
function parseProviderSurfaceKindExpectation(value) {
|
function parseProviderSurfaceKindExpectation(value) {
|
||||||
const parts = value.split(":");
|
const parts = value.split(":");
|
||||||
if (parts.length !== 3 || parts.some(part => !part)) {
|
if (parts.length !== 3 || parts.some(part => !part)) {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
"schemas/gamecult.eve.plugin_receipt.v1.schema.json",
|
"schemas/gamecult.eve.plugin_receipt.v1.schema.json",
|
||||||
"schemas/gamecult.eve.plugin_advertisement.v1.schema.json",
|
"schemas/gamecult.eve.plugin_advertisement.v1.schema.json",
|
||||||
"schemas/gamecult.eve.plugin_handoff.v1.schema.json",
|
"schemas/gamecult.eve.plugin_handoff.v1.schema.json",
|
||||||
|
"schemas/gamecult.eve.plugin_witness.v1.schema.json",
|
||||||
"schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json",
|
"schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json",
|
||||||
"schemas/gamecult.eve.plugin_abi.request.v1.schema.json",
|
"schemas/gamecult.eve.plugin_abi.request.v1.schema.json",
|
||||||
"schemas/gamecult.eve.plugin_abi.response.v1.schema.json",
|
"schemas/gamecult.eve.plugin_abi.response.v1.schema.json",
|
||||||
|
|
@ -88,6 +89,7 @@
|
||||||
"gamecult.eve.plugin_receipt.v1",
|
"gamecult.eve.plugin_receipt.v1",
|
||||||
"gamecult.eve.plugin_advertisement.v1",
|
"gamecult.eve.plugin_advertisement.v1",
|
||||||
"gamecult.eve.plugin_handoff.v1",
|
"gamecult.eve.plugin_handoff.v1",
|
||||||
|
"gamecult.eve.plugin_witness.v1",
|
||||||
"gamecult.eve.plugin_abi_fixture.v1",
|
"gamecult.eve.plugin_abi_fixture.v1",
|
||||||
"gamecult.eve.plugin_abi.request.v1",
|
"gamecult.eve.plugin_abi.request.v1",
|
||||||
"gamecult.eve.plugin_abi.response.v1",
|
"gamecult.eve.plugin_abi.response.v1",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"gamecult.eve.plugin_abi_fixture.v1": "schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json",
|
"gamecult.eve.plugin_abi_fixture.v1": "schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json",
|
||||||
"gamecult.eve.plugin_advertisement.v1": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json",
|
"gamecult.eve.plugin_advertisement.v1": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json",
|
||||||
"gamecult.eve.plugin_handoff.v1": "schemas/gamecult.eve.plugin_handoff.v1.schema.json",
|
"gamecult.eve.plugin_handoff.v1": "schemas/gamecult.eve.plugin_handoff.v1.schema.json",
|
||||||
|
"gamecult.eve.plugin_witness.v1": "schemas/gamecult.eve.plugin_witness.v1.schema.json",
|
||||||
"gamecult.eve.runtime_capability.v1": "schemas/gamecult.eve.runtime_capability.v1.schema.json",
|
"gamecult.eve.runtime_capability.v1": "schemas/gamecult.eve.runtime_capability.v1.schema.json",
|
||||||
"gamecult.eve.runtime_lifecycle.v1": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json",
|
"gamecult.eve.runtime_lifecycle.v1": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json",
|
||||||
"gamecult.eve.runtime_split_handoff.v1": "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json",
|
"gamecult.eve.runtime_split_handoff.v1": "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json",
|
||||||
|
|
|
||||||
159
tools/plugins/run-sidecar-witness.mjs
Normal file
159
tools/plugins/run-sidecar-witness.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import readline from "node:readline";
|
||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
|
||||||
|
const options = parseArguments(process.argv.slice(2));
|
||||||
|
const advertisement = parseJson(await readFile(options.advertisement, "utf8"));
|
||||||
|
const fixture = parseJson(await readFile(options.fixture, "utf8"));
|
||||||
|
validateInputs(advertisement, fixture, options);
|
||||||
|
|
||||||
|
const started = performance.now();
|
||||||
|
const child = spawn(options.command, options.commandArgs, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
const lines = readline.createInterface({ input: child.stdout });
|
||||||
|
const responses = [];
|
||||||
|
const waiters = [];
|
||||||
|
lines.on("line", line => {
|
||||||
|
const waiter = waiters.shift();
|
||||||
|
if (!waiter) return;
|
||||||
|
try { waiter.resolve(parseJson(line)); } catch (error) { waiter.reject(error); }
|
||||||
|
});
|
||||||
|
let stderr = "";
|
||||||
|
child.stderr.on("data", chunk => { stderr += chunk.toString(); });
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [index, operation] of fixture.operations.entries()) {
|
||||||
|
const requestId = `${fixture.fixtureId}:${operation.operation}:${index}`;
|
||||||
|
const request = {
|
||||||
|
schema: fixture.requestSchema,
|
||||||
|
pluginId: fixture.pluginId,
|
||||||
|
operation: operation.operation,
|
||||||
|
requestId,
|
||||||
|
input: operation.input || {},
|
||||||
|
context: { witnessId: options.witnessId },
|
||||||
|
targetRuntime: "eve-plugin-witness",
|
||||||
|
};
|
||||||
|
const operationStarted = performance.now();
|
||||||
|
const responsePromise = nextResponse(options.timeoutMs);
|
||||||
|
child.stdin.write(`${JSON.stringify(request)}\n`);
|
||||||
|
const response = await responsePromise;
|
||||||
|
validateResponse(response, request, operation.expect || {});
|
||||||
|
responses.push({
|
||||||
|
operation: operation.operation,
|
||||||
|
requestId,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: round(performance.now() - operationStarted),
|
||||||
|
expectationCount: Object.keys(operation.expect || {}).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
child.stdin.end();
|
||||||
|
await Promise.race([new Promise(resolve => child.once("exit", resolve)), delay(1000)]);
|
||||||
|
if (child.exitCode === null) child.kill();
|
||||||
|
lines.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const witness = {
|
||||||
|
schema: "gamecult.eve.plugin_witness.v1",
|
||||||
|
witnessId: options.witnessId,
|
||||||
|
pluginId: fixture.pluginId,
|
||||||
|
ownerRepo: fixture.ownerRepo,
|
||||||
|
status: "pass",
|
||||||
|
transport: advertisement.runtime?.sidecar?.protocol || advertisement.runtime?.transports?.[0] || "",
|
||||||
|
generatedAtUtc: new Date().toISOString(),
|
||||||
|
durationMs: round(performance.now() - started),
|
||||||
|
operations: responses,
|
||||||
|
advertisementPath: slash(path.relative(options.cwd, options.advertisement)),
|
||||||
|
fixturePath: slash(path.relative(options.cwd, options.fixture)),
|
||||||
|
executable: {
|
||||||
|
artifact: advertisement.runtime?.sidecar?.launch?.artifact || "",
|
||||||
|
command: advertisement.runtime?.sidecar?.launch?.command || path.basename(options.command),
|
||||||
|
},
|
||||||
|
diagnostics: stderr.trim() ? [stderr.trim()] : [],
|
||||||
|
authority: "plugin-sidecar-owns-semantic-projection-and-proposes-state-provider-retains-acceptance",
|
||||||
|
};
|
||||||
|
await mkdir(path.dirname(options.output), { recursive: true });
|
||||||
|
await writeFile(options.output, `${JSON.stringify(witness, null, 2)}\n`);
|
||||||
|
console.log(options.output);
|
||||||
|
|
||||||
|
function nextResponse(timeoutMs) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`Plugin sidecar response timed out after ${timeoutMs}ms.`)), timeoutMs);
|
||||||
|
waiters.push({
|
||||||
|
resolve: value => { clearTimeout(timer); resolve(value); },
|
||||||
|
reject: error => { clearTimeout(timer); reject(error); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInputs(advertisement, fixture, options) {
|
||||||
|
if (advertisement.schema !== "gamecult.eve.plugin_advertisement.v1") throw new Error("Plugin advertisement schema is invalid.");
|
||||||
|
if (fixture.schema !== "gamecult.eve.plugin_abi_fixture.v1") throw new Error("Plugin ABI fixture schema is invalid.");
|
||||||
|
if (advertisement.pluginId !== fixture.pluginId) throw new Error("Plugin advertisement and fixture ids differ.");
|
||||||
|
if (!advertisement.runtime?.sidecar?.operations?.length) throw new Error("Plugin advertisement has no sidecar operations.");
|
||||||
|
for (const operation of fixture.operations || []) {
|
||||||
|
if (!advertisement.runtime.sidecar.operations.includes(operation.operation)) throw new Error(`Operation ${operation.operation} is not advertised.`);
|
||||||
|
}
|
||||||
|
if (!options.command) throw new Error("Plugin sidecar command is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResponse(response, request, expect) {
|
||||||
|
for (const [field, value] of Object.entries({
|
||||||
|
schema: "gamecult.eve.plugin_abi.response.v1",
|
||||||
|
pluginId: request.pluginId,
|
||||||
|
operation: request.operation,
|
||||||
|
requestId: request.requestId,
|
||||||
|
status: "accepted",
|
||||||
|
})) {
|
||||||
|
if (response?.[field] !== value) throw new Error(`${request.operation} response ${field}: expected ${value}, got ${response?.[field] || ""}`);
|
||||||
|
}
|
||||||
|
assertSubset(response.output, expect, request.operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSubset(actual, expected, label) {
|
||||||
|
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||||
|
if (key === "measurementOutputs") {
|
||||||
|
for (const outputKey of expectedValue) if (!(outputKey in (actual || {}))) throw new Error(`${label} output missing ${outputKey}.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const actualValue = actual?.[key];
|
||||||
|
if (Array.isArray(expectedValue)) {
|
||||||
|
if (!Array.isArray(actualValue) || expectedValue.some(value => !actualValue.includes(value))) throw new Error(`${label} output ${key} does not include expected values.`);
|
||||||
|
} else if (expectedValue && typeof expectedValue === "object") {
|
||||||
|
assertSubset(actualValue, expectedValue, `${label}.${key}`);
|
||||||
|
} else if (actualValue !== expectedValue) {
|
||||||
|
throw new Error(`${label} output ${key}: expected ${expectedValue}, got ${actualValue}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(args) {
|
||||||
|
const values = { commandArgs: [], timeoutMs: 5000 };
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const key = args[index];
|
||||||
|
if (key === "--") { values.commandArgs = args.slice(index + 1); break; }
|
||||||
|
const value = args[++index];
|
||||||
|
if (!value) throw new Error(`Missing value for ${key}.`);
|
||||||
|
if (key === "--advertisement") values.advertisement = path.resolve(value);
|
||||||
|
else if (key === "--fixture") values.fixture = path.resolve(value);
|
||||||
|
else if (key === "--output") values.output = path.resolve(value);
|
||||||
|
else if (key === "--witness-id") values.witnessId = value;
|
||||||
|
else if (key === "--cwd") values.cwd = path.resolve(value);
|
||||||
|
else if (key === "--command") values.command = value;
|
||||||
|
else if (key === "--timeout-ms") values.timeoutMs = Number(value);
|
||||||
|
else throw new Error(`Unknown option ${key}.`);
|
||||||
|
}
|
||||||
|
values.cwd ||= process.cwd();
|
||||||
|
for (const field of ["advertisement", "fixture", "output", "witnessId", "command"]) if (!values[field]) throw new Error(`Missing --${field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}.`);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); }
|
||||||
|
function round(value) { return Math.round(value * 1000) / 1000; }
|
||||||
|
function slash(value) { return value.replaceAll("\\", "/"); }
|
||||||
|
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
||||||
Loading…
Reference in a new issue