test(s3): add game logic consumer contract
This commit is contained in:
parent
9fe3e277ba
commit
b0fe408bcb
@ -0,0 +1,503 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RuntimeSdkContractVersion } from "../../../../../packages/shared-contracts/src/runtime-sdk-contract.js";
|
||||
import { validateAndWriteGameLogicModuleArtifact } from "./game-logic-validator.js";
|
||||
import {
|
||||
FileSystemGameLogicArtifactRepository,
|
||||
GameLogicArtifactRepositoryError,
|
||||
checksumText,
|
||||
isRepositoryRelativePath,
|
||||
type GameLogicArtifactRepository,
|
||||
type ValidationReport
|
||||
} from "./artifact-repository.js";
|
||||
import { GameLogicCompilerService, type GameLogicModuleDescriptor } from "./index.js";
|
||||
import type { GameConfig } from "../game-config/index.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
const buildInputPath = "apps/api/src/modules/game-logic/fixtures/task6-light.game-config.json";
|
||||
const alternateCheckedFilePath = "dist/game-logic/task6-consumer/other.mjs";
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("S3 S4/S5 mock consumer contract", () => {
|
||||
it("S4 web builder mock reads descriptor routing fields from repository-backed artifact", async () => {
|
||||
const context = await createConsumerContext();
|
||||
|
||||
await expect(consumeForS4WebBuilder(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "accepted",
|
||||
artifactPath: context.descriptor.artifactPath,
|
||||
validationReportId: context.descriptor.validationReportId,
|
||||
runtimeSdkContractVersion: RuntimeSdkContractVersion,
|
||||
checksum: context.descriptor.checksum
|
||||
});
|
||||
});
|
||||
|
||||
it("S4 web builder mock rejects missing runtimeSdkContractVersion", async () => {
|
||||
const context = await createConsumerContext({ descriptorOverrides: { runtimeSdkContractVersion: undefined } });
|
||||
|
||||
await expect(consumeForS4WebBuilder(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "S4_RUNTIME_SDK_CONTRACT_VERSION_MISSING",
|
||||
path: "runtimeSdkContractVersion"
|
||||
});
|
||||
});
|
||||
|
||||
it("S5 converter mock reads sourceRef, exportSignature and buildGraphId from repository-backed artifact", async () => {
|
||||
const context = await createConsumerContext();
|
||||
|
||||
await expect(consumeForS5Converter(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "accepted",
|
||||
sourceRef: context.descriptor.sourceRef,
|
||||
exportSignature: context.descriptor.exportSignature,
|
||||
buildGraphId: context.descriptor.buildGraphId,
|
||||
checksum: context.descriptor.checksum
|
||||
});
|
||||
});
|
||||
|
||||
it("S5 converter mock rejects checksum mismatch with stable reasonCode", async () => {
|
||||
const context = await createConsumerContext({ descriptorOverrides: { checksum: checksumText("descriptor tamper") } });
|
||||
|
||||
await expect(consumeForS5Converter(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "VALIDATION_REPORT_CHECKSUM_MISMATCH",
|
||||
path: "validationReportId"
|
||||
});
|
||||
});
|
||||
|
||||
it("S5 converter mock rejects descriptor validationStatus self-report before trusting report", async () => {
|
||||
const context = await createConsumerContext({
|
||||
writeReport: false,
|
||||
descriptorOverrides: { validationStatus: "validated" }
|
||||
});
|
||||
|
||||
await expect(consumeForS5Converter(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "GAME_LOGIC_VALIDATION_STATUS_SELF_REPORTED",
|
||||
path: "validationStatus"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder],
|
||||
["S5", consumeForS5Converter]
|
||||
] as const)("%s mock rejects missing ValidationReport", async (_name, consume) => {
|
||||
const context = await createConsumerContext({ writeReport: false });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "GAME_LOGIC_VALIDATION_REPORT_MISSING",
|
||||
path: "validationReportId"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder],
|
||||
["S5", consumeForS5Converter]
|
||||
] as const)("%s mock rejects ValidationReport.result != passed", async (_name, consume) => {
|
||||
const context = await createConsumerContext({ reportOverrides: { result: "failed" } });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "VALIDATION_REPORT_FAILED",
|
||||
path: "validationReportId"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder],
|
||||
["S5", consumeForS5Converter]
|
||||
] as const)("%s mock rejects ValidationReport missing validatorVersion", async (_name, consume) => {
|
||||
const context = await createConsumerContext({ omitReportValidatorVersion: true });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "VALIDATION_REPORT_VALIDATOR_VERSION_MISSING",
|
||||
path: "validatorVersion"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder, "checksum", { checksum: checksumText("report checksum tamper") }, "VALIDATION_REPORT_CHECKSUM_MISMATCH", "validationReportId"],
|
||||
["S4", consumeForS4WebBuilder, "sourceHash", { sourceHash: checksumText("source hash tamper") }, "VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "sourceRef.buildInputPath"],
|
||||
["S4", consumeForS4WebBuilder, "artifactPath", { artifactPath: "dist/game-logic/other/logic.mjs" }, "VALIDATION_REPORT_ARTIFACT_PATH_MISMATCH", "validationReportId"],
|
||||
["S5", consumeForS5Converter, "checksum", { checksum: checksumText("report checksum tamper") }, "VALIDATION_REPORT_CHECKSUM_MISMATCH", "validationReportId"],
|
||||
["S5", consumeForS5Converter, "sourceHash", { sourceHash: checksumText("source hash tamper") }, "VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "sourceRef.buildInputPath"],
|
||||
["S5", consumeForS5Converter, "artifactPath", { artifactPath: "dist/game-logic/other/logic.mjs" }, "VALIDATION_REPORT_ARTIFACT_PATH_MISMATCH", "validationReportId"]
|
||||
] as const)("%s mock rejects ValidationReport %s mismatch", async (_name, consume, _field, reportOverrides, reasonCode, failurePath) => {
|
||||
const context = await createConsumerContext({ reportOverrides });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode,
|
||||
path: failurePath
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder],
|
||||
["S5", consumeForS5Converter]
|
||||
] as const)("%s mock rejects artifact checksum mismatch after repository artifact is tampered", async (_name, consume) => {
|
||||
const context = await createConsumerContext();
|
||||
await context.repository.writeLogicArtifact({ artifactPath: context.descriptor.artifactPath, source: "export const tampered = true;" });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "GAME_LOGIC_ARTIFACT_CHECKSUM_MISMATCH",
|
||||
path: "artifactPath"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder],
|
||||
["S5", consumeForS5Converter]
|
||||
] as const)("%s mock rejects ValidationReport checkedFiles missing descriptor artifactPath", async (_name, consume) => {
|
||||
const context = await createConsumerContext({ rawReportOverrides: { checkedFiles: [alternateCheckedFilePath] } });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "VALIDATION_REPORT_CHECKED_FILES_MISSING_ARTIFACT",
|
||||
path: "checkedFiles"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder],
|
||||
["S5", consumeForS5Converter]
|
||||
] as const)("%s mock reads ValidationReport and artifact through GameLogicArtifactRepository", async (_name, consume) => {
|
||||
const context = await createConsumerContext();
|
||||
const repository = trackRepositoryReads(context.repository);
|
||||
|
||||
await expect(consume(repository, context.descriptor)).resolves.toMatchObject({ status: "accepted" });
|
||||
expect(repository.readValidationReport).toHaveBeenCalledWith(context.descriptor.validationReportId);
|
||||
expect(repository.resolveLogicArtifact).toHaveBeenCalledWith(context.descriptor.sourceRef.buildInputPath);
|
||||
expect(repository.resolveLogicArtifact).toHaveBeenCalledWith(context.descriptor.artifactPath);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder, "/tmp/logic.mjs"],
|
||||
["S4", consumeForS4WebBuilder, "../escape/logic.mjs"],
|
||||
["S5", consumeForS5Converter, "/tmp/logic.mjs"],
|
||||
["S5", consumeForS5Converter, "../escape/logic.mjs"]
|
||||
] as const)("%s mock rejects unsafe descriptor artifactPath %s", async (_name, consume, artifactPath) => {
|
||||
const context = await createConsumerContext({ descriptorOverrides: { artifactPath } });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "GAME_LOGIC_ARTIFACT_PATH_UNSAFE",
|
||||
path: "artifactPath"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["S4", consumeForS4WebBuilder, "/tmp/logic.mjs"],
|
||||
["S4", consumeForS4WebBuilder, "../escape/logic.mjs"],
|
||||
["S5", consumeForS5Converter, "/tmp/logic.mjs"],
|
||||
["S5", consumeForS5Converter, "../escape/logic.mjs"]
|
||||
] as const)("%s mock rejects unsafe ValidationReport.checkedFiles %s", async (_name, consume, checkedFile) => {
|
||||
const context = await createConsumerContext({ rawReportOverrides: { checkedFiles: [checkedFile] } });
|
||||
|
||||
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
|
||||
status: "rejected",
|
||||
reasonCode: "GAME_LOGIC_ARTIFACT_PATH_UNSAFE"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ConsumerName = "S4" | "S5";
|
||||
type MockConsumerResult =
|
||||
| {
|
||||
readonly status: "accepted";
|
||||
readonly consumer: ConsumerName;
|
||||
readonly artifactPath: string;
|
||||
readonly validationReportId: string;
|
||||
readonly runtimeSdkContractVersion: string;
|
||||
readonly checksum: string;
|
||||
readonly artifactSource: string;
|
||||
readonly sourceRef?: GameLogicModuleDescriptor["sourceRef"];
|
||||
readonly exportSignature?: GameLogicModuleDescriptor["exportSignature"];
|
||||
readonly buildGraphId?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "rejected";
|
||||
readonly consumer: ConsumerName;
|
||||
readonly reasonCode: string;
|
||||
readonly path?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type UnsafeMutableDescriptor = Omit<GameLogicModuleDescriptor, "runtimeSdkContractVersion"> & {
|
||||
readonly runtimeSdkContractVersion?: string;
|
||||
readonly validationStatus?: unknown;
|
||||
};
|
||||
|
||||
type ConsumerContextOptions = {
|
||||
readonly writeReport?: boolean;
|
||||
readonly descriptorOverrides?: Record<string, unknown>;
|
||||
readonly reportOverrides?: Partial<ValidationReport>;
|
||||
readonly rawReportOverrides?: Record<string, unknown>;
|
||||
readonly omitReportValidatorVersion?: boolean;
|
||||
};
|
||||
|
||||
type ConsumerContext = {
|
||||
readonly repository: FileSystemGameLogicArtifactRepository;
|
||||
readonly descriptor: UnsafeMutableDescriptor;
|
||||
readonly report: ValidationReport | null;
|
||||
};
|
||||
|
||||
async function consumeForS4WebBuilder(repository: GameLogicArtifactRepository, descriptor: UnsafeMutableDescriptor): Promise<MockConsumerResult> {
|
||||
const selfReport = rejectDescriptorSelfReport("S4", descriptor);
|
||||
if (selfReport !== null) return selfReport;
|
||||
|
||||
if (typeof descriptor.runtimeSdkContractVersion !== "string" || descriptor.runtimeSdkContractVersion.length === 0) {
|
||||
return rejected("S4", "S4_RUNTIME_SDK_CONTRACT_VERSION_MISSING", "runtimeSdkContractVersion");
|
||||
}
|
||||
if (descriptor.runtimeSdkContractVersion !== RuntimeSdkContractVersion) {
|
||||
return rejected("S4", "S4_RUNTIME_SDK_CONTRACT_VERSION_INVALID", "runtimeSdkContractVersion", {
|
||||
expected: RuntimeSdkContractVersion,
|
||||
actual: descriptor.runtimeSdkContractVersion
|
||||
});
|
||||
}
|
||||
|
||||
const verified = await readVerifiedConsumerArtifact("S4", repository, descriptor);
|
||||
if (verified.status === "rejected") return verified;
|
||||
return {
|
||||
status: "accepted",
|
||||
consumer: "S4",
|
||||
artifactPath: descriptor.artifactPath,
|
||||
validationReportId: descriptor.validationReportId,
|
||||
runtimeSdkContractVersion: descriptor.runtimeSdkContractVersion,
|
||||
checksum: descriptor.checksum,
|
||||
artifactSource: verified.artifactSource
|
||||
};
|
||||
}
|
||||
|
||||
async function consumeForS5Converter(repository: GameLogicArtifactRepository, descriptor: UnsafeMutableDescriptor): Promise<MockConsumerResult> {
|
||||
const selfReport = rejectDescriptorSelfReport("S5", descriptor);
|
||||
if (selfReport !== null) return selfReport;
|
||||
|
||||
if (!isSourceRefReadable(descriptor.sourceRef)) return rejected("S5", "S5_SOURCE_REF_MISSING", "sourceRef");
|
||||
if (!isExportSignatureReadable(descriptor.exportSignature)) return rejected("S5", "S5_EXPORT_SIGNATURE_MISSING", "exportSignature");
|
||||
if (typeof descriptor.buildGraphId !== "string" || descriptor.buildGraphId.length === 0) return rejected("S5", "S5_BUILD_GRAPH_ID_MISSING", "buildGraphId");
|
||||
|
||||
const verified = await readVerifiedConsumerArtifact("S5", repository, descriptor);
|
||||
if (verified.status === "rejected") return verified;
|
||||
return {
|
||||
status: "accepted",
|
||||
consumer: "S5",
|
||||
artifactPath: descriptor.artifactPath,
|
||||
validationReportId: descriptor.validationReportId,
|
||||
runtimeSdkContractVersion: descriptor.runtimeSdkContractVersion ?? "",
|
||||
checksum: descriptor.checksum,
|
||||
artifactSource: verified.artifactSource,
|
||||
sourceRef: descriptor.sourceRef,
|
||||
exportSignature: descriptor.exportSignature,
|
||||
buildGraphId: descriptor.buildGraphId
|
||||
};
|
||||
}
|
||||
|
||||
async function readVerifiedConsumerArtifact(
|
||||
consumer: ConsumerName,
|
||||
repository: GameLogicArtifactRepository,
|
||||
descriptor: UnsafeMutableDescriptor
|
||||
): Promise<MockConsumerResult | { readonly status: "accepted"; readonly artifactSource: string }> {
|
||||
if (!isRepositoryRelativePath(descriptor.artifactPath)) return rejected(consumer, "GAME_LOGIC_ARTIFACT_PATH_UNSAFE", "artifactPath");
|
||||
if (!isRepositoryRelativePath(descriptor.sourceRef.buildInputPath)) return rejected(consumer, "GAME_LOGIC_ARTIFACT_PATH_UNSAFE", "sourceRef.buildInputPath");
|
||||
|
||||
const reportResult = await readReportThroughRepository(consumer, repository, descriptor.validationReportId);
|
||||
if (reportResult.status === "rejected") return reportResult;
|
||||
const report = reportResult.report;
|
||||
|
||||
if (report.result !== "passed") return rejected(consumer, "VALIDATION_REPORT_FAILED", "validationReportId", { validationReportId: report.id });
|
||||
if (typeof report.validatorVersion !== "string" || report.validatorVersion.length === 0) {
|
||||
return rejected(consumer, "VALIDATION_REPORT_VALIDATOR_VERSION_MISSING", "validatorVersion");
|
||||
}
|
||||
if (report.artifactPath !== descriptor.artifactPath) {
|
||||
return rejected(consumer, "VALIDATION_REPORT_ARTIFACT_PATH_MISMATCH", "validationReportId", {
|
||||
descriptorArtifactPath: descriptor.artifactPath,
|
||||
reportArtifactPath: report.artifactPath
|
||||
});
|
||||
}
|
||||
if (!report.checkedFiles.includes(descriptor.artifactPath)) {
|
||||
return rejected(consumer, "VALIDATION_REPORT_CHECKED_FILES_MISSING_ARTIFACT", "checkedFiles", {
|
||||
artifactPath: descriptor.artifactPath,
|
||||
checkedFiles: report.checkedFiles
|
||||
});
|
||||
}
|
||||
if (report.checksum !== descriptor.checksum) {
|
||||
return rejected(consumer, "VALIDATION_REPORT_CHECKSUM_MISMATCH", "validationReportId", {
|
||||
descriptorChecksum: descriptor.checksum,
|
||||
reportChecksum: report.checksum
|
||||
});
|
||||
}
|
||||
|
||||
const sourceInput = await resolveArtifactThroughRepository(consumer, repository, descriptor.sourceRef.buildInputPath);
|
||||
if (sourceInput.status === "rejected") return sourceInput;
|
||||
if (report.sourceHash !== sourceInput.checksum) {
|
||||
return rejected(consumer, "VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "sourceRef.buildInputPath", {
|
||||
expectedSourceHash: sourceInput.checksum,
|
||||
reportSourceHash: report.sourceHash
|
||||
});
|
||||
}
|
||||
|
||||
const artifact = await resolveArtifactThroughRepository(consumer, repository, descriptor.artifactPath);
|
||||
if (artifact.status === "rejected") return artifact;
|
||||
if (artifact.checksum !== descriptor.checksum) {
|
||||
return rejected(consumer, "GAME_LOGIC_ARTIFACT_CHECKSUM_MISMATCH", "artifactPath", {
|
||||
descriptorChecksum: descriptor.checksum,
|
||||
artifactChecksum: artifact.checksum
|
||||
});
|
||||
}
|
||||
|
||||
return { status: "accepted", artifactSource: artifact.source };
|
||||
}
|
||||
|
||||
async function readReportThroughRepository(
|
||||
consumer: ConsumerName,
|
||||
repository: GameLogicArtifactRepository,
|
||||
validationReportId: string
|
||||
): Promise<{ readonly status: "accepted"; readonly report: ValidationReport } | MockConsumerResult> {
|
||||
try {
|
||||
const report = await repository.readValidationReport(validationReportId);
|
||||
if (report === null) return rejected(consumer, "GAME_LOGIC_VALIDATION_REPORT_MISSING", "validationReportId", { validationReportId });
|
||||
return { status: "accepted", report };
|
||||
} catch (error) {
|
||||
return rejectedFromRepositoryError(consumer, error, "validationReportId");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveArtifactThroughRepository(
|
||||
consumer: ConsumerName,
|
||||
repository: GameLogicArtifactRepository,
|
||||
artifactPath: string
|
||||
): Promise<{ readonly status: "accepted"; readonly source: string; readonly checksum: string } | MockConsumerResult> {
|
||||
try {
|
||||
return { status: "accepted", ...(await repository.resolveLogicArtifact(artifactPath)) };
|
||||
} catch (error) {
|
||||
return rejectedFromRepositoryError(consumer, error, "artifactPath");
|
||||
}
|
||||
}
|
||||
|
||||
function rejectDescriptorSelfReport(consumer: ConsumerName, descriptor: UnsafeMutableDescriptor): MockConsumerResult | null {
|
||||
// descriptor 是不可信上游输入;消费者必须先拒绝自报 validated,再读取 validator-owned report。
|
||||
if (Object.prototype.hasOwnProperty.call(descriptor, "validationStatus")) {
|
||||
return rejected(consumer, "GAME_LOGIC_VALIDATION_STATUS_SELF_REPORTED", "validationStatus");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rejectedFromRepositoryError(consumer: ConsumerName, error: unknown, fallbackPath: string): MockConsumerResult {
|
||||
if (error instanceof GameLogicArtifactRepositoryError) {
|
||||
return rejected(consumer, error.reasonCode, error.path ?? fallbackPath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function rejected(consumer: ConsumerName, reasonCode: string, failurePath: string, details?: Record<string, unknown>): MockConsumerResult {
|
||||
return details === undefined
|
||||
? { status: "rejected", consumer, reasonCode, path: failurePath }
|
||||
: { status: "rejected", consumer, reasonCode, path: failurePath, details };
|
||||
}
|
||||
|
||||
async function createConsumerContext(options: ConsumerContextOptions = {}): Promise<ConsumerContext> {
|
||||
const repository = await createRepository();
|
||||
const compilerResult = await new GameLogicCompilerService(repository).compileGameLogicModule(createLightGameConfig(), { buildInputPath });
|
||||
let report: ValidationReport | null = null;
|
||||
|
||||
if (options.writeReport !== false) {
|
||||
await expect(validateAndWriteGameLogicModuleArtifact(repository, compilerResult.descriptor)).resolves.toEqual({ status: "valid" });
|
||||
report = await repository.readValidationReport(compilerResult.descriptor.validationReportId);
|
||||
expect(report).not.toBeNull();
|
||||
const overriddenReport = applyReportOverrides(report as ValidationReport, options);
|
||||
await writeReportForFixture(repository, overriddenReport, options.rawReportOverrides !== undefined);
|
||||
report = overriddenReport;
|
||||
}
|
||||
|
||||
return {
|
||||
repository,
|
||||
descriptor: { ...compilerResult.descriptor, ...options.descriptorOverrides } as UnsafeMutableDescriptor,
|
||||
report
|
||||
};
|
||||
}
|
||||
|
||||
async function createRepository(): Promise<FileSystemGameLogicArtifactRepository> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s3-task6-"));
|
||||
tempRoots.push(root);
|
||||
return new FileSystemGameLogicArtifactRepository({ root });
|
||||
}
|
||||
|
||||
function applyReportOverrides(report: ValidationReport, options: ConsumerContextOptions): ValidationReport {
|
||||
const merged = { ...report, ...options.reportOverrides, ...options.rawReportOverrides } as ValidationReport & Record<string, unknown>;
|
||||
if (options.omitReportValidatorVersion === true) delete merged.validatorVersion;
|
||||
return merged as ValidationReport;
|
||||
}
|
||||
|
||||
async function writeReportForFixture(repository: FileSystemGameLogicArtifactRepository, report: ValidationReport, writeRaw: boolean): Promise<void> {
|
||||
if (!writeRaw) {
|
||||
await repository.writeValidationReport(report);
|
||||
return;
|
||||
}
|
||||
|
||||
// raw report 只用于模拟磁盘中已存在的恶意/损坏 report,验证 consumer 仍通过 repository read path fail-closed。
|
||||
const reportPath = path.join(repository.root, "reports", `${report.id}.json`);
|
||||
await mkdir(path.dirname(reportPath), { recursive: true });
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function trackRepositoryReads(repository: FileSystemGameLogicArtifactRepository): GameLogicArtifactRepository & {
|
||||
readonly readValidationReport: ReturnType<typeof vi.fn<GameLogicArtifactRepository["readValidationReport"]>>;
|
||||
readonly resolveLogicArtifact: ReturnType<typeof vi.fn<GameLogicArtifactRepository["resolveLogicArtifact"]>>;
|
||||
} {
|
||||
return {
|
||||
writeLogicArtifact: repository.writeLogicArtifact.bind(repository),
|
||||
writeValidationReport: repository.writeValidationReport.bind(repository),
|
||||
readValidationReport: vi.fn(repository.readValidationReport.bind(repository)),
|
||||
resolveLogicArtifact: vi.fn(repository.resolveLogicArtifact.bind(repository))
|
||||
};
|
||||
}
|
||||
|
||||
function isSourceRefReadable(value: unknown): value is GameLogicModuleDescriptor["sourceRef"] {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as GameLogicModuleDescriptor["sourceRef"]).gameConfigId === "string" &&
|
||||
typeof (value as GameLogicModuleDescriptor["sourceRef"]).templateVersion === "string" &&
|
||||
typeof (value as GameLogicModuleDescriptor["sourceRef"]).buildInputPath === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isExportSignatureReadable(value: unknown): value is GameLogicModuleDescriptor["exportSignature"] {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
["init", "update", "render", "handleInput", "onPause", "onResume"].every((entrypoint) => typeof (value as Record<string, unknown>)[entrypoint] === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function createLightGameConfig(): GameConfig {
|
||||
return {
|
||||
id: "game-config-task6-consumer",
|
||||
sourceGameIrArtifactId: "game-ir-artifact-task6-consumer",
|
||||
sourceGameIrChecksum: "sha256:game-ir-artifact-task6-consumer",
|
||||
projectId: "project-task6-consumer",
|
||||
versionId: "version-task6-consumer",
|
||||
templateType: "light_collect",
|
||||
world: {
|
||||
id: "world-task6-consumer",
|
||||
type: "single_screen",
|
||||
loop: {
|
||||
playerAction: "tap_collect",
|
||||
spawnRule: "spawn_star_every_2_seconds",
|
||||
scoreResourceId: "star",
|
||||
winCondition: "collect_20_stars"
|
||||
}
|
||||
},
|
||||
systems: [{ id: "light-loop-system", type: "light_loop", enabled: true, tickIntervalMs: 1000 }],
|
||||
resources: [{ id: "star", initial: 0, min: 0, max: 20, kind: "score" }],
|
||||
controls: [{ id: "tap-collect", action: "collect", inputType: "touch", pointerRegion: { x: 0, y: 0, width: 320, height: 180 } }],
|
||||
objectives: [{ id: "collect-20-stars", kind: "resource_at_least", target: "star", value: 20 }]
|
||||
};
|
||||
}
|
||||
115
docs/memorys/2026-06-04-S3Task6消费者合同.md
Normal file
115
docs/memorys/2026-06-04-S3Task6消费者合同.md
Normal file
@ -0,0 +1,115 @@
|
||||
# 2026-06-04 S3 Task6 消费者合同
|
||||
|
||||
## 结论
|
||||
|
||||
S3 Task6 `Add S4/S5 Mock Consumer Tests` 已完成实现、controller 自测,并通过 fresh review gate:
|
||||
|
||||
- spec compliance review:PASS,无 Critical / Important。
|
||||
- quality / feasibility review:PASS,无 Critical / Important。
|
||||
|
||||
可以提交 Task6,并在提交后进入 S3 Task7:`Write Runtime And Conversion Backend Presearch Note`。
|
||||
|
||||
## 变更范围
|
||||
|
||||
- `apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts`
|
||||
|
||||
未修改 production code、shared contracts、scope gate、package exports,也未实现真实 S4/S5 runtime/conversion、feed、deploy、routes、UI 或 Task7 presearch note。
|
||||
|
||||
## 子代理与 Review Gate
|
||||
|
||||
| Role | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementer | `019e9306-1e3a-7a01-ac94-fc385e95e830` | DONE | 无;warning 为既有 `pg@9` deprecation warning |
|
||||
| spec compliance review | `019e9313-2db5-7483-babe-d31918146bab` | PASS | 无 Critical / Important |
|
||||
| quality / feasibility review | `019e9313-8293-7c01-9c63-546167503a15` | PASS | 无 Critical / Important |
|
||||
|
||||
## 实现摘要
|
||||
|
||||
- 新增 test-local S4 web builder mock consumer。
|
||||
- 新增 test-local S5 converter mock consumer。
|
||||
- 两个 mock consumer 都只通过 `GameLogicArtifactRepository.readValidationReport` 和 `resolveLogicArtifact` 读取 report、source input 和 artifact。
|
||||
- 测试复用:
|
||||
- Task4 `GameLogicCompilerService` 生成 descriptor/artifact。
|
||||
- Task5 `validateAndWriteGameLogicModuleArtifact` 写 validator-owned passed report。
|
||||
- 覆盖 S4:
|
||||
- 读取 `artifactPath`、`validationReportId`、`runtimeSdkContractVersion`、`checksum`。
|
||||
- 拒绝 missing `runtimeSdkContractVersion`。
|
||||
- 覆盖 S5:
|
||||
- 读取 `sourceRef`、`exportSignature`、`buildGraphId`。
|
||||
- 拒绝 checksum mismatch。
|
||||
- 拒绝 descriptor `validationStatus` self-report。
|
||||
- 覆盖 S4/S5 共同 gate:
|
||||
- missing `ValidationReport`。
|
||||
- `ValidationReport.result != "passed"`。
|
||||
- missing `validatorVersion`。
|
||||
- report checksum mismatch。
|
||||
- report sourceHash mismatch。
|
||||
- report artifactPath mismatch。
|
||||
- artifact tamper 后 checksum mismatch。
|
||||
- checkedFiles missing descriptor artifactPath。
|
||||
- descriptor artifactPath absolute path 和 `../` traversal。
|
||||
- report checkedFiles absolute path 和 `../` traversal。
|
||||
|
||||
## Controller 自测
|
||||
|
||||
已运行并通过:
|
||||
|
||||
```bash
|
||||
test -f apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts
|
||||
pnpm --filter @huijing/api test -- src/modules/game-logic/game-logic-consumer-contract.spec.ts
|
||||
pnpm --filter @huijing/api typecheck
|
||||
pnpm check:s3-scope
|
||||
git diff --check
|
||||
```
|
||||
|
||||
关键输出:
|
||||
|
||||
```text
|
||||
Test Files 21 passed (21)
|
||||
Tests 212 passed (212)
|
||||
S3 scope check passed.
|
||||
```
|
||||
|
||||
`pnpm --filter @huijing/api test -- src/modules/game-logic/game-logic-consumer-contract.spec.ts` 仍输出既有 `pg@9` deprecation warning,退出码为 0。
|
||||
|
||||
## Review Evidence
|
||||
|
||||
Spec reviewer 额外确认:
|
||||
|
||||
```text
|
||||
当前实现范围:tracked 源码 diff 为空;未跟踪文件仅有 game-logic-consumer-contract.spec.ts。
|
||||
未发现真实 S4/S5 runtime/conversion/feed/deploy/routes/UI 或 Task7 presearch note 实现。
|
||||
Task6 测试覆盖 S4/S5 mock 读取 descriptor 字段、拒绝 missing/failed/malformed/mismatched report、descriptor self-report、unsafe artifactPath 与 checkedFiles,并验证通过 readValidationReport 和 resolveLogicArtifact 读取 repository 数据。
|
||||
```
|
||||
|
||||
Quality reviewer 额外确认:
|
||||
|
||||
```text
|
||||
S4/S5 mock consumer 都通过 repository 读取 ValidationReport、source input 和 artifact。
|
||||
S4/S5 有角色差异断言。
|
||||
raw report 直写仅在测试 fixture 中模拟恶意/损坏磁盘 report。
|
||||
指定 production 文件与 package/export 文件无 diff。
|
||||
```
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts` | `1d005e3ca20df8d8b65f8155d567d08def73ffc8e0ab74978253cd45e254a62e` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S3-simulation-slice.md` | `cbc7fb2afaab3cf64b578984b9def7ef265a46aa07029b732ac4ecbc2d68b76a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S3-simulation-slice-design.md` | `08abff96b9a1a7b7680e71678f5c479edab24467dd10185c0b0b0e29ea7e613e` |
|
||||
|
||||
## Git Status Snapshot
|
||||
|
||||
写入本文档前 `git status --short --branch --untracked-files=all`:
|
||||
|
||||
```text
|
||||
## codex/s1-task9-s2-prep...origin/codex/s1-task9-s2-prep [ahead 7]
|
||||
?? apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts
|
||||
```
|
||||
|
||||
## Residual Notes
|
||||
|
||||
- Task7 是文档型 runtime/conversion backend presearch note;不要把 Task6 mock consumer tests 当成真实 S4/S5 implementation。
|
||||
- 后续 consumer 仍必须只信 repository 中 validator-owned `ValidationReport.result="passed"`,不能信 descriptor 自报。
|
||||
- 后续每个 task 继续使用 fresh implementer + controller 自测 + fresh spec/quality review;实现子代理等待上限 30 分钟,review 子代理等待上限 15 分钟。
|
||||
Loading…
x
Reference in New Issue
Block a user