Files
codex-game-studio/tests/customization.test.ts
T
MerlinH 60969ea9b0 fix: honor review fix flags for custom roles
Render read-only review prompts and bounded fix prompts for project-local custom role runs instead of accepting --review/--fix without lifecycle prompts.

Add regression coverage and update routed Truthmark docs for the custom role review/fix contract.
2026-06-26 00:31:03 +10:00

241 lines
14 KiB
TypeScript

import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { appendApprovalRecord, canonicalObjectiveSha256 } from "../src/approvals.js";
import { customizationConfigPath, readProjectCustomization } from "../src/customization.js";
import { initProject, statusProject } from "../src/projects.js";
import { prepareRun } from "../src/runner.js";
import { formatTemplateShow, listTemplates } from "../src/templates.js";
import { validateProject } from "../src/validation.js";
import { renderWorkflowPrompt } from "../src/workflows.js";
function writeValidCustomPack(projectRoot: string, phase: "plan" | "implement" = "plan"): void {
mkdirSync(path.join(projectRoot, ".codex", "custom", "roles"), { recursive: true });
mkdirSync(path.join(projectRoot, ".codex", "workflows"), { recursive: true });
mkdirSync(path.join(projectRoot, "documentation", "templates"), { recursive: true });
writeFileSync(path.join(projectRoot, ".codex", "custom", "roles", "boss-designer.md"), "# Boss Designer Prompt\n\nDesign readable boss fights with phases, tells, counters, accessibility notes, and verification gates.\n");
writeFileSync(path.join(projectRoot, ".codex", "workflows", "custom-boss-review.md"), "# Boss Review Workflow\n\nUse phase readability, counterplay, accessibility, and test evidence to review the encounter.\n");
writeFileSync(path.join(projectRoot, "documentation", "templates", "boss-brief.md"), "# Purpose\n\nCapture a boss encounter brief.\n\n# Inputs\n\nProject fantasy and combat constraints.\n\n# Outputs\n\nPhases, tells, counters, risks, and tests.\n\n# Validation\n\nPlaytest the encounter for readability.\n");
writeFileSync(
customizationConfigPath(projectRoot),
`${JSON.stringify(
{
schemaVersion: 1,
policy: { merge: "extend-only" },
roles: [
{
id: "custom-boss-designer",
displayName: "Boss Designer",
promptFile: ".codex/custom/roles/boss-designer.md",
contextStrategy: "focused",
phase,
expectedOutputs: ["Boss encounter brief", "Readability risks", "Playtest checks"],
reviewChecklist: ["Boss phases are readable", "Counterplay is explicit", "Verification is concrete"]
}
],
templates: [
{
id: "custom-boss-brief",
category: "design",
path: "documentation/templates/boss-brief.md",
description: "Project-local boss encounter brief template.",
roles: ["custom-boss-designer"],
workflows: ["custom-boss-review"],
tags: ["boss", "encounter", "custom"],
requiredSections: ["# Purpose", "# Inputs", "# Outputs", "# Validation"]
}
],
workflows: [
{
id: "custom-boss-review",
role: "custom-boss-designer",
phase: "plan",
objective: "Draft or review a boss encounter for readable phases, counterplay, accessibility, and test evidence.",
file: ".codex/workflows/custom-boss-review.md",
contextFiles: ["AGENTS.md", ".codex/studio.json", ".codex/custom/roles/boss-designer.md"],
templateIds: ["custom-boss-brief"],
aliases: ["boss-review"]
}
]
},
null,
2
)}\n`
);
}
describe("project-local customization packs", () => {
test("init seeds a reviewable customization config and status summarizes it", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-init-"));
const { projectRoot } = initProject({ name: "Custom Seed", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
expect(existsSync(customizationConfigPath(projectRoot))).toBe(true);
expect(readProjectCustomization(projectRoot)).toMatchObject({ schemaVersion: 1, roles: [], workflows: [], templates: [], policy: { merge: "extend-only" } });
expect(statusProject(projectRoot, cwd)).toContain("custom roles: 0, workflows: 0, templates: 0");
expect(validateProject(projectRoot)).toContainEqual(expect.objectContaining({ id: "codex.customization.config", status: "pass" }));
});
test("valid local role workflow and template packs merge with built-ins without loading broad context", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-valid-"));
const { projectRoot } = initProject({ name: "Boss Game", engine: "godot", mode: "development", nonInteractive: true }, cwd);
writeValidCustomPack(projectRoot);
const checks = validateProject(projectRoot);
expect(checks).toContainEqual(expect.objectContaining({ id: "codex.customization.role.custom-boss-designer", status: "pass" }));
expect(checks).toContainEqual(expect.objectContaining({ id: "codex.customization.workflow.custom-boss-review", status: "pass" }));
expect(checks).toContainEqual(expect.objectContaining({ id: "codex.customization.template.custom-boss-brief", status: "pass" }));
expect(checks.filter((check) => check.id.startsWith("codex.customization") && check.status === "fail")).toEqual([]);
expect(listTemplates(projectRoot).map((template) => template.id)).toContain("custom-boss-brief");
expect(formatTemplateShow("custom-boss-brief", { projectRoot })).toContain("Project-local boss encounter brief template");
const workflowPrompt = renderWorkflowPrompt(projectRoot, "custom-boss-review");
expect(workflowPrompt).toContain("Boss Designer");
expect(workflowPrompt).toContain("Draft or review a boss encounter");
expect(workflowPrompt).toContain("Boss Review Workflow");
expect(workflowPrompt).toContain("Use phase readability, counterplay, accessibility, and test evidence");
expect(workflowPrompt).toContain("Template: custom-boss-brief");
expect(workflowPrompt).not.toContain("Template: market_analysis");
const run = prepareRun("custom-boss-designer", { project: projectRoot, task: "Draft a boss fight brief with phase readability", printPrompt: true }, cwd);
expect(run.output).toContain("Role: Boss Designer");
expect(run.output).toContain("Role ID: custom-boss-designer");
expect(run.output).toContain("Boss Designer Prompt");
expect(run.output).toContain("Template: custom-boss-brief");
});
test("custom role runs render review and fix prompts when requested", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-review-fix-"));
const { projectRoot } = initProject({ name: "Custom Review Fix", engine: "godot", mode: "prototype", studioMode: "fast-prototype", nonInteractive: true }, cwd);
writeValidCustomPack(projectRoot, "implement");
const run = prepareRun("custom-boss-designer", { project: projectRoot, task: "Draft a boss fight brief with phase readability", dryRun: true, review: true, fix: true }, cwd);
expect(run.reviewPrompt).toContain("Read-only review: inspect diff and verification output; do not edit files.");
expect(run.reviewPrompt).toContain(".codex/prompts/qa-playtester.md");
expect(run.fixPrompt).toContain("# Project Custom Role Prompt: .codex/custom/roles/boss-designer.md");
expect(run.fixPrompt).toContain("Bounded Blockers:");
expect(run.reviewCodexCommand?.args).toEqual(expect.arrayContaining(["--sandbox", "read-only"]));
expect(run.output).toContain("Review Codex command:");
expect(run.output).toContain("Expected review JSON schema:");
expect(run.output).toContain("Fix prompt (max passes: 1):");
});
test("project-local workflows can render with built-in roles accepted by validation", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-built-in-workflow-"));
const { projectRoot } = initProject({ name: "Built In Workflow", engine: "godot", mode: "development", nonInteractive: true }, cwd);
mkdirSync(path.join(projectRoot, ".codex", "workflows"), { recursive: true });
writeFileSync(path.join(projectRoot, ".codex", "workflows", "custom-production-review.md"), "# Production Review\n\nReview scope, risks, owners, and validation gates.\n");
writeFileSync(
customizationConfigPath(projectRoot),
`${JSON.stringify(
{
schemaVersion: 1,
policy: { merge: "extend-only" },
roles: [],
templates: [],
workflows: [
{
id: "custom-production-review",
role: "producer",
phase: "plan",
objective: "Review production scope, risks, owners, and validation gates.",
file: ".codex/workflows/custom-production-review.md",
contextFiles: ["documentation/production/timeline.md"],
templateIds: [],
aliases: []
}
]
},
null,
2
)}\n`
);
expect(validateProject(projectRoot)).toContainEqual(expect.objectContaining({ id: "codex.customization.workflow.custom-production-review", status: "pass" }));
const workflowPrompt = renderWorkflowPrompt(projectRoot, "custom-production-review");
expect(workflowPrompt).toContain("Role: Producer");
expect(workflowPrompt).toContain("Role ID: producer");
expect(workflowPrompt).toContain("Production Review");
expect(workflowPrompt).toContain("Review production scope, risks, owners, and validation gates.");
});
test("custom implement roles can run under strict studio with a matching approval", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-approval-"));
const { projectRoot } = initProject({ name: "Custom Approval", engine: "godot", mode: "development", studioMode: "strict-studio", nonInteractive: true }, cwd);
writeValidCustomPack(projectRoot, "implement");
const task = "Implement a boss encounter controller";
const approvedGlobs = ["source/**/*.gd"];
const objectiveSha256 = canonicalObjectiveSha256({
role: "custom-boss-designer",
objective: task,
approvedGlobs,
projectStage: "development",
studioMode: "strict-studio"
});
appendApprovalRecord(projectRoot, {
role: "custom-boss-designer",
objectiveSha256,
objective: task,
projectStage: "development",
studioMode: "strict-studio",
approvedGlobs,
approvedBy: "lead"
});
const run = prepareRun("custom-boss-designer", { project: projectRoot, task, dryRun: true, approvalScope: approvedGlobs }, cwd);
expect(run.output).toContain("Eligibility: allowed");
expect(run.output).toContain("Write policy: approved-write");
expect(run.output).toContain("Matching approval: true");
expect(run.output).toContain("Approval diagnostic:");
expect(run.output).toContain("approval-001: authorizing");
});
test("customization validation rejects a custom workflow with a missing file", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-missing-workflow-"));
const { projectRoot } = initProject({ name: "Missing Workflow", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeValidCustomPack(projectRoot);
const workflowFile = path.join(projectRoot, ".codex", "workflows", "custom-boss-review.md");
expect(existsSync(workflowFile)).toBe(true);
unlinkSync(workflowFile);
const workflowCheck = validateProject(projectRoot).find((check) => check.id === "codex.customization.workflow.custom-boss-review");
expect(workflowCheck).toMatchObject({ status: "fail" });
expect(workflowCheck?.message).toContain("workflow file missing: .codex/workflows/custom-boss-review.md");
});
test("customization validation rejects unsafe paths and built-in id conflicts", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-invalid-"));
const { projectRoot } = initProject({ name: "Invalid Custom", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeFileSync(
customizationConfigPath(projectRoot),
`${JSON.stringify(
{
schemaVersion: 1,
policy: { merge: "extend-only" },
roles: [{ id: "producer", displayName: "Producer Override", promptFile: ".codex/custom/roles/producer.md", contextStrategy: "focused", phase: "plan", expectedOutputs: ["Override"], reviewChecklist: ["Override"] }],
templates: [{ id: "custom-escape-template", category: "design", path: "../escape.md", description: "Unsafe template path.", roles: ["producer"], workflows: ["vertical-slice"], tags: ["unsafe"], requiredSections: ["# Purpose"] }],
workflows: []
},
null,
2
)}\n`
);
const failures = validateProject(projectRoot).filter((check) => check.id.startsWith("codex.customization") && check.status === "fail");
expect(failures.map((failure) => failure.id)).toEqual(expect.arrayContaining(["codex.customization.role.producer", "codex.customization.template.custom-escape-template"]));
expect(failures.map((failure) => failure.message).join("\n")).toMatch(/built-in|escape|project root/i);
});
test("CLI templates and workflow commands can inspect project-local packs", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "ogs-custom-cli-"));
const { projectRoot } = initProject({ name: "CLI Custom", engine: "godot", mode: "prototype", nonInteractive: true }, cwd);
writeValidCustomPack(projectRoot);
const cli = path.join(process.cwd(), "src", "cli.ts");
const tsx = path.join(process.cwd(), "node_modules", ".bin", "tsx");
const templates = execFileSync(tsx, [cli, "templates", "list", "--project", projectRoot], { cwd, encoding: "utf8" });
expect(templates).toContain("custom-boss-brief");
const workflow = execFileSync(tsx, [cli, "workflow", "custom-boss-review", "--project", projectRoot, "--dry-run"], { cwd, encoding: "utf8" });
expect(workflow).toContain("Boss Designer");
expect(workflow).toContain("Template: custom-boss-brief");
});
});