import { spawnSync } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; const repoRoot = process.cwd(); const tempWorkspaces = new Set(); afterEach(async () => { await Promise.all([...tempWorkspaces].map((workspace) => rm(workspace, { recursive: true, force: true }))); tempWorkspaces.clear(); }); describe("development scripts", () => { it.each([ { message: "feat: add side chats", expectedStatus: 0 }, { message: "Merge pull request #123 from owner/feature", expectedStatus: 0 }, { message: 'Revert "feat: add side chats"', expectedStatus: 1 }, { message: "v5.1.0", expectedStatus: 1 }, { message: "fixup! feat: add side chats", expectedStatus: 1 }, { message: "squash! feat: add side chats", expectedStatus: 1 }, ])("enforces the commit message policy for $message", ({ message, expectedStatus }) => { expect(runCommitlint(message)).toBe(expectedStatus); }); it("fails style builds when CSS files are missing from the style order file", async () => { const cwd = await styleOrderFixture(); const result = runNodeScript("scripts/build-styles.mjs", [], cwd); expect(result.status).toBe(1); expect(result.stderr).toContain("CSS files missing from src/styles/order.json: 10-unlisted.css"); }); it("passes the expected codex generate-ts arguments", async () => { const cwd = await tempWorkspace(); const calls: { command: string; args: string[]; cwd: string }[] = []; const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href); await writeAppServerCompatibility(cwd, "0.144.4", ["app-server", "generate-ts", "--experimental", "--test-setting"]); await generateAppServerTypes({ cwd, readCodexVersion: () => "0.144.4", async runCommand(command: string, args: string[], options: { cwd: string }) { calls.push({ command, args, cwd: options.cwd }); const outIndex = args.indexOf("--out"); const outputDir = args[outIndex + 1]; if (!outputDir) throw new Error("Missing generated output directory"); await mkdir(path.join(options.cwd, outputDir, "v2"), { recursive: true }); await writeFile( path.join(options.cwd, outputDir, "v2", "Example.ts"), "// GENERATED CODE! DO NOT MODIFY BY HAND!\nexport type Example = string | null | null;\n", ); }, }); expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ command: "codex", cwd }); expect(calls[0]?.args.slice(0, 5)).toEqual(["app-server", "generate-ts", "--experimental", "--test-setting", "--out"]); expect(calls[0]?.args[5]?.replaceAll("\\", "/")).toMatch(/^src\/generated\/\.app-server-/); await expect(readFile(path.join(cwd, "src", "generated", "app-server", "v2", "Example.ts"), "utf8")).resolves.toContain( "export type Example = string | null;", ); }); it("preserves generated bindings when generation fails", async () => { const cwd = await tempWorkspace(); const generatedDir = path.join(cwd, "src", "generated", "app-server"); await mkdir(generatedDir, { recursive: true }); await writeFile(path.join(generatedDir, "Existing.ts"), "export type Existing = true;\n"); await writeAppServerCompatibility(cwd, "0.144.4"); const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href); await expect( generateAppServerTypes({ cwd, readCodexVersion: () => "0.144.4", runCommand: async () => { throw new Error("generation failed"); }, }), ).rejects.toThrow("generation failed"); await expect(readFile(path.join(generatedDir, "Existing.ts"), "utf8")).resolves.toBe("export type Existing = true;\n"); await expect(readdir(path.join(cwd, "src", "generated"))).resolves.toEqual(["app-server"]); }); it("checks normalized generated bindings without replacing the tracked tree", async () => { const cwd = await tempWorkspace(); const generatedDir = path.join(cwd, "src", "generated", "app-server"); await mkdir(path.join(generatedDir, "v2"), { recursive: true }); await writeFile( path.join(generatedDir, "v2", "Example.ts"), "// GENERATED CODE! DO NOT MODIFY BY HAND!\n// This file was mechanically normalized after generation by scripts/generate-app-server-types.mjs.\nexport type Example = string | null;\n", ); await writeAppServerCompatibility(cwd, "0.144.4"); const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href); const generate = async (_command: string, args: string[], options: { cwd: string }) => { const outputDir = args[args.indexOf("--out") + 1]; if (!outputDir) throw new Error("Missing generated output directory"); await mkdir(path.join(options.cwd, outputDir, "v2"), { recursive: true }); await writeFile( path.join(options.cwd, outputDir, "v2", "Example.ts"), "// GENERATED CODE! DO NOT MODIFY BY HAND!\nexport type Example = string | null | null;\n", ); }; await expect( generateAppServerTypes({ cwd, check: true, readCodexVersion: () => "0.144.4", runCommand: generate }), ).resolves.toBeUndefined(); const trackedSource = await readFile(path.join(generatedDir, "v2", "Example.ts"), "utf8"); await expect( generateAppServerTypes({ cwd, check: true, readCodexVersion: () => "0.144.4", async runCommand(command: string, args: string[], options: { cwd: string }) { await generate(command, args, options); const outputDir = args[args.indexOf("--out") + 1]; if (!outputDir) throw new Error("Missing generated output directory"); await writeFile(path.join(options.cwd, outputDir, "v2", "Extra.ts"), "export type Extra = true;\n"); }, }), ).rejects.toThrow("generated app-server bindings are out of date:\n added: v2/Extra.ts"); await expect(readFile(path.join(generatedDir, "v2", "Example.ts"), "utf8")).resolves.toBe(trackedSource); await expect(readdir(path.join(cwd, "src", "generated"))).resolves.toEqual(["app-server"]); }); it("refuses to generate bindings with a different Codex CLI patch", async () => { const cwd = await tempWorkspace(); await writeAppServerCompatibility(cwd, "0.144.4"); const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href); await expect(generateAppServerTypes({ cwd, readCodexVersion: () => "0.144.5", runCommand: async () => undefined })).rejects.toThrow( "Codex CLI 0.144.4 is required to generate app-server bindings; found 0.144.5.", ); }); it("reads app-server compatibility policy from the declared baseline in API baseline checks", async () => { const cwd = await tempWorkspace(); await mkdir(path.join(cwd, "src", "app-server"), { recursive: true }); const { createApiBaselineReport } = await import(pathToFileURL(path.join(repoRoot, "scripts", "api-baseline.mjs")).href); await writeJson(path.join(cwd, "package.json"), { version: "1.0.0", devDependencies: { obsidian: "~1.12.3", }, }); await writeJson(path.join(cwd, "package-lock.json"), { packages: { "node_modules/obsidian": { version: "1.12.3", }, }, }); await writeJson(path.join(cwd, "manifest.json"), { minAppVersion: "1.12.0", }); await writeJson(path.join(cwd, "versions.json"), { "1.0.0": "1.12.0", }); await writeFile( path.join(cwd, "README.md"), [ "## Compatibility", "", "| Key | Version | Notes |", "| --- | --- | --- |", "| `codex.testedCliVersion` | `0.139.0` | Tested CLI. |", "| `manifest.minAppVersion` | `1.12.0` | Minimum app version. |", "| `obsidian` API types | `1.12.3` | Compile-time API package. |", ].join("\n"), ); await mkdir(path.join(cwd, "src", "app-server", "connection"), { recursive: true }); await writeJson(path.join(cwd, "src", "app-server", "connection", "compatibility.json"), { codexAppServer: { testedCliVersion: "0.139.0", typeGeneration: { arguments: ["app-server", "generate-ts", "--experimental"], }, initialize: { capabilities: { experimentalApi: true, requestAttestation: false, }, }, }, }); const report = await createApiBaselineReport({ cwd, readCodexVersion: () => "0.139.0", }); expect(report.codex.recordedTestedCliVersion).toBe("0.139.0"); expect(report.codex.generationArguments).toEqual(["app-server", "generate-ts", "--experimental"]); expect(report.codex.readmeMatchesRecordedVersion).toBe(true); expect(report.codex.localCliMatchesRecordedVersion).toBe(true); expect(report.codex.initializeExperimentalApi).toBe(true); expect(report.codex.initializeRequestAttestationDisabled).toBe(true); expect(report.failures).toEqual([]); const missingLocalCliReport = await createApiBaselineReport({ cwd, readCodexVersion: () => null, }); expect(missingLocalCliReport.failures).toContain("local codex --version could not be read."); }); it("rejects the obsolete recorded-only API baseline mode", () => { const result = runNodeScript("scripts/api-baseline.mjs", ["--recorded-only"], repoRoot); expect(result.status).toBe(1); expect(result.stderr).toContain("Usage: node scripts/api-baseline.mjs [--json]"); }); it("reports app-server provenance drift between the recorded, README, and local CLI versions", async () => { const cwd = await apiBaselineFixture({ recordedCodexVersion: "0.144.4", readmeCodexVersion: "0.144.0" }); const { createApiBaselineReport } = await import(pathToFileURL(path.join(repoRoot, "scripts", "api-baseline.mjs")).href); const report = await createApiBaselineReport({ cwd, readCodexVersion: () => "0.144.5" }); expect(report.failures).toContain("README Codex CLI 0.144.0 does not match recorded tested CLI 0.144.4."); expect(report.failures).toContain("local Codex CLI 0.144.5 does not match recorded tested CLI 0.144.4."); }); it("checks generated app-server bindings in CI and release paths", async () => { const [checkWorkflow, releaseWorkflow, releasePreflight] = await Promise.all([ readFile(path.join(repoRoot, ".github", "workflows", "check.yml"), "utf8"), readFile(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8"), readFile(path.join(repoRoot, "scripts", "release", "preflight.mjs"), "utf8"), ]); for (const workflow of [checkWorkflow, releaseWorkflow]) { expect(workflow).toContain("npm install --global"); expect(workflow).toContain("node scripts/app-server-compatibility.mjs --tested-cli-version"); expect(workflow).toContain("npm run generate:app-server-types:check"); } expect(releasePreflight).toContain('run("npm", ["run", "generate:app-server-types:check"]);'); }); it("reports representative CSS usage policy failures", async () => { const cwd = await cssUsageFixture({ "src/styles/10-component.css": [ ".codex-panel__used { display: block; }", ".codex-panel__test-only { display: block; }", ".codex-panel__unused { display: block; }", ].join("\n"), "src/component.ts": [ 'export const className = "codex-panel__used";', "export const dynamicClassName = `codex-panel__task-step--$" + "{status}`;", ].join("\n"), "tests/component.test.ts": 'expect("codex-panel__test-only").toBeTruthy();\n', }); const result = runNodeScript("scripts/check-css-usage.mjs", [], cwd); expect(result.status).toBe(1); expect(result.stdout).toBe(""); expect(result.stderr).toContain("CSS usage check failed."); expect(result.stderr).toContain("Dynamic CSS class prefixes are not allowed:"); expect(result.stderr).toContain("codex-panel__task-step--"); expect(result.stderr).toContain("codex-panel__test-only"); expect(result.stderr).toContain("codex-panel__unused"); }); it("reports unused panel custom properties", async () => { const cwd = await cssUsageFixture({ "src/styles/10-component.css": [ ".codex-panel__used {", " --codex-panel-used-size: 1px;", " --codex-panel-unused-size: 2px;", " width: var(--codex-panel-used-size);", " /*", " --codex-panel-commented-size: 3px;", " width: var(--codex-panel-unused-size);", " */", "}", ].join("\n"), "src/component.ts": 'export const className = "codex-panel__used";\n', }); const result = runNodeScript("scripts/check-css-usage.mjs", [], cwd); expect(result.status).toBe(1); expect(result.stderr).toContain("Unused CSS custom property candidates:"); expect(result.stderr).toContain("--codex-panel-unused-size"); expect(result.stderr).not.toContain("--codex-panel-used-size\n"); expect(result.stderr).not.toContain("--codex-panel-commented-size"); }); it("does not count a longer source class name as usage", async () => { const cwd = await cssUsageFixture({ "src/styles/10-component.css": [".codex-panel__item { display: block; }", ".codex-panel__item-detail { display: block; }"].join("\n"), "src/component.ts": 'export const className = "codex-panel__item-detail";\n', }); const result = runNodeScript("scripts/check-css-usage.mjs", [], cwd); expect(result.status).toBe(1); expect(result.stderr).toContain("Unused CSS class candidates:"); expect(result.stderr).toContain(" codex-panel__item\n"); }); it("prepares release notes from conventional commits since the previous tag", async () => { const cwd = await tempWorkspace(); await writeJson(path.join(cwd, "package.json"), { version: "2.3.2" }); await writeJson(path.join(cwd, "package-lock.json"), { version: "2.3.2", packages: { "": { version: "2.3.2" } }, }); await writeJson(path.join(cwd, "manifest.json"), { version: "2.3.2", minAppVersion: "1.12.0" }); await writeJson(path.join(cwd, "versions.json"), { "2.3.2": "1.12.0" }); runGit(["init"], cwd); runGit(["config", "user.name", "Codex Panel Tests"], cwd); runGit(["config", "user.email", "tests@example.com"], cwd); runGit(["add", "."], cwd); runGit(["commit", "-m", "chore: establish release baseline"], cwd); runGit(["tag", "2.3.2"], cwd); await writeFile(path.join(cwd, "change.txt"), "side chats\n"); runGit(["add", "change.txt"], cwd); runGit(["commit", "-m", "feat(chat): add side chat support"], cwd); const result = runNodeScript("scripts/release/prepare.mjs", ["2.4.0"], cwd); expect(result.status).toBe(0); await expect(readFile(path.join(cwd, ".github", "release-notes", "2.4.0.md"), "utf8")).resolves.toBe( "## Changes\n\n- Add side chat support.\n", ); await expect(readJson(path.join(cwd, "package.json"))).resolves.toMatchObject({ version: "2.4.0" }); }); it("fails release prepare before changing version files when release notes already exist", async () => { const cwd = await tempWorkspace(); await mkdir(path.join(cwd, ".github", "release-notes"), { recursive: true }); const packageJson = { version: "2.3.2" }; const packageLockJson = { version: "2.3.2", packages: { "": { version: "2.3.2" } } }; const manifestJson = { version: "2.3.2", minAppVersion: "1.12.0" }; const versionsJson = { "2.3.2": "1.12.0" }; await writeJson(path.join(cwd, "package.json"), packageJson); await writeJson(path.join(cwd, "package-lock.json"), packageLockJson); await writeJson(path.join(cwd, "manifest.json"), manifestJson); await writeJson(path.join(cwd, "versions.json"), versionsJson); await writeFile(path.join(cwd, ".github", "release-notes", "2.3.3.md"), "## Changes\n\n- Existing\n"); const result = runNodeScript("scripts/release/prepare.mjs", ["2.3.3"], cwd); expect(result.status).toBe(1); expect(result.stderr).toContain(".github/release-notes/2.3.3.md already exists"); await expect(readJson(path.join(cwd, "package.json"))).resolves.toEqual(packageJson); await expect(readJson(path.join(cwd, "package-lock.json"))).resolves.toEqual(packageLockJson); await expect(readJson(path.join(cwd, "manifest.json"))).resolves.toEqual(manifestJson); await expect(readJson(path.join(cwd, "versions.json"))).resolves.toEqual(versionsJson); }); }); async function tempWorkspace(): Promise { const workspace = await mkdtemp(path.join(tmpdir(), "codex-panel-scripts-")); tempWorkspaces.add(workspace); return workspace; } async function styleOrderFixture(): Promise { const cwd = await tempWorkspace(); await mkdir(path.join(cwd, "src", "styles"), { recursive: true }); await writeJson(path.join(cwd, "src", "styles", "order.json"), ["00-tokens.css"]); await writeFile(path.join(cwd, "src", "styles", "00-tokens.css"), ".codex-panel { color: var(--text-normal); }\n"); await writeFile(path.join(cwd, "src", "styles", "10-unlisted.css"), ".codex-panel__extra { display: block; }\n"); return cwd; } async function apiBaselineFixture(options: { recordedCodexVersion: string; readmeCodexVersion: string }): Promise { const cwd = await tempWorkspace(); await writeJson(path.join(cwd, "package.json"), { version: "1.0.0", devDependencies: { obsidian: "~1.12.3" } }); await writeJson(path.join(cwd, "package-lock.json"), { packages: { "node_modules/obsidian": { version: "1.12.3" } }, }); await writeJson(path.join(cwd, "manifest.json"), { minAppVersion: "1.12.0" }); await writeJson(path.join(cwd, "versions.json"), { "1.0.0": "1.12.0" }); await writeFile( path.join(cwd, "README.md"), [ "## Compatibility", "", "| Key | Version | Notes |", "| --- | --- | --- |", `| \`codex.testedCliVersion\` | \`${options.readmeCodexVersion}\` | Tested CLI. |`, "| `manifest.minAppVersion` | `1.12.0` | Minimum app version. |", "| `obsidian` API types | `1.12.3` | Compile-time API package. |", ].join("\n"), ); await writeAppServerCompatibility(cwd, options.recordedCodexVersion); return cwd; } async function writeAppServerCompatibility( cwd: string, testedCliVersion: string, generationArguments = ["app-server", "generate-ts", "--experimental"], ): Promise { await mkdir(path.join(cwd, "src", "app-server", "connection"), { recursive: true }); await writeJson(path.join(cwd, "src", "app-server", "connection", "compatibility.json"), { codexAppServer: { testedCliVersion, typeGeneration: { arguments: generationArguments, }, initialize: { capabilities: { experimentalApi: true, requestAttestation: false, }, }, }, }); } async function cssUsageFixture(files: Record): Promise { const cwd = await tempWorkspace(); await mkdir(path.join(cwd, "src", "styles"), { recursive: true }); await mkdir(path.join(cwd, "tests"), { recursive: true }); await writeJson(path.join(cwd, "src", "styles", "order.json"), ["10-component.css"]); for (const [file, source] of Object.entries(files)) { await mkdir(path.dirname(path.join(cwd, file)), { recursive: true }); await writeFile(path.join(cwd, file), source); } return cwd; } function runNodeScript(script: string, args: string[] = [], cwd = repoRoot, env: NodeJS.ProcessEnv = {}) { return spawnSync(process.execPath, [path.join(repoRoot, script), ...args], { cwd, encoding: "utf8", env: { ...process.env, ...env }, shell: false, }); } function runCommitlint(message: string): number | null { return spawnSync(process.execPath, [path.join(repoRoot, "node_modules", "@commitlint", "cli", "cli.js")], { cwd: repoRoot, encoding: "utf8", input: `${message}\n`, shell: false, }).status; } function runGit(args: string[], cwd: string): void { const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false }); if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); } async function writeJson(file: string, value: unknown): Promise { await writeFile(file, `${JSON.stringify(value, null, 2)}\n`); } async function readJson(file: string): Promise { return JSON.parse(await readFile(file, "utf8")); }