2026-05-30 01:22:32 +00:00
|
|
|
import { spawnSync } from "node:child_process";
|
2026-07-10 03:50:37 +00:00
|
|
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
2026-05-30 01:22:32 +00:00
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
import path from "node:path";
|
2026-06-25 10:05:43 +00:00
|
|
|
import { pathToFileURL } from "node:url";
|
2026-07-10 03:50:37 +00:00
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
2026-05-30 01:22:32 +00:00
|
|
|
|
|
|
|
|
const repoRoot = process.cwd();
|
2026-07-10 03:50:37 +00:00
|
|
|
const tempWorkspaces = new Set<string>();
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all([...tempWorkspaces].map((workspace) => rm(workspace, { recursive: true, force: true })));
|
|
|
|
|
tempWorkspaces.clear();
|
|
|
|
|
});
|
2026-05-30 01:22:32 +00:00
|
|
|
|
|
|
|
|
describe("development scripts", () => {
|
2026-07-14 06:53:19 +00:00
|
|
|
it("enforces Conventional Commits except for GitHub merge commits", () => {
|
|
|
|
|
for (const message of ["feat: add side chats", "Merge pull request #123 from owner/feature"]) {
|
|
|
|
|
expect(runCommitlint(message), message).toBe(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const message of ['Revert "feat: add side chats"', "v5.1.0", "fixup! feat: add side chats", "squash! feat: add side chats"]) {
|
|
|
|
|
expect(runCommitlint(message), message).toBe(1);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-31 11:26:11 +00:00
|
|
|
it("fails style builds when CSS files are missing from the style order file", async () => {
|
2026-06-19 23:18:19 +00:00
|
|
|
const cwd = await styleOrderFixture();
|
2026-05-30 02:00:08 +00:00
|
|
|
|
2026-06-19 23:18:19 +00:00
|
|
|
const result = runNodeScript("scripts/build-styles.mjs", [], cwd);
|
2026-05-30 02:00:08 +00:00
|
|
|
|
|
|
|
|
expect(result.status).toBe(1);
|
2026-05-31 11:26:11 +00:00
|
|
|
expect(result.stderr).toContain("CSS files missing from src/styles/order.json: 10-unlisted.css");
|
2026-05-30 02:00:08 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-30 01:22:32 +00:00
|
|
|
it("passes the expected codex generate-ts arguments", async () => {
|
|
|
|
|
const cwd = await tempWorkspace();
|
2026-06-25 10:05:43 +00:00
|
|
|
const calls: { command: string; args: string[]; cwd: string }[] = [];
|
|
|
|
|
const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href);
|
|
|
|
|
|
|
|
|
|
await generateAppServerTypes({
|
|
|
|
|
cwd,
|
|
|
|
|
async runCommand(command: string, args: string[], options: { cwd: string }) {
|
|
|
|
|
calls.push({ command, args, cwd: options.cwd });
|
2026-07-10 03:46:38 +00:00
|
|
|
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 });
|
2026-06-25 10:05:43 +00:00
|
|
|
await writeFile(
|
2026-07-10 03:46:38 +00:00
|
|
|
path.join(options.cwd, outputDir, "v2", "Example.ts"),
|
2026-06-25 10:05:43 +00:00
|
|
|
"// GENERATED CODE! DO NOT MODIFY BY HAND!\nexport type Example = string | null | null;\n",
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-05-30 01:22:32 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-10 03:46:38 +00:00
|
|
|
expect(calls).toHaveLength(1);
|
|
|
|
|
expect(calls[0]).toMatchObject({ command: "codex", cwd });
|
|
|
|
|
expect(calls[0]?.args.slice(0, 4)).toEqual(["app-server", "generate-ts", "--experimental", "--out"]);
|
|
|
|
|
expect(calls[0]?.args[4]?.replaceAll("\\", "/")).toMatch(/^src\/generated\/\.app-server-/);
|
2026-05-30 01:22:32 +00:00
|
|
|
await expect(readFile(path.join(cwd, "src", "generated", "app-server", "v2", "Example.ts"), "utf8")).resolves.toContain(
|
|
|
|
|
"export type Example = string | null;",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-10 03:46:38 +00:00
|
|
|
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");
|
|
|
|
|
const { generateAppServerTypes } = await import(pathToFileURL(path.join(repoRoot, "scripts", "generate-app-server-types.mjs")).href);
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
generateAppServerTypes({
|
|
|
|
|
cwd,
|
|
|
|
|
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"]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-07 13:06:19 +00:00
|
|
|
it("reads app-server compatibility policy from the declared baseline in API baseline checks", async () => {
|
2026-06-12 09:21:17 +00:00
|
|
|
const cwd = await tempWorkspace();
|
|
|
|
|
await mkdir(path.join(cwd, "scripts"), { recursive: true });
|
2026-07-07 13:06:19 +00:00
|
|
|
await mkdir(path.join(cwd, "src", "app-server"), { recursive: true });
|
2026-06-26 02:39:55 +00:00
|
|
|
const { createApiBaselineReport } = await import(pathToFileURL(path.join(repoRoot, "scripts", "api-baseline.mjs")).href);
|
2026-06-12 09:21:17 +00:00
|
|
|
|
|
|
|
|
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. |",
|
2026-06-19 11:11:51 +00:00
|
|
|
"| `manifest.minAppVersion` | `1.12.0` | Minimum app version. |",
|
|
|
|
|
"| `obsidian` API types | `1.12.3` | Compile-time API package. |",
|
2026-06-12 09:21:17 +00:00
|
|
|
].join("\n"),
|
|
|
|
|
);
|
|
|
|
|
await writeFile(
|
|
|
|
|
path.join(cwd, "scripts", "generate-app-server-types.mjs"),
|
|
|
|
|
'run("codex", ["app-server", "generate-ts", "--experimental"]);\n',
|
|
|
|
|
);
|
2026-07-07 13:06:19 +00:00
|
|
|
await mkdir(path.join(cwd, "src", "app-server", "connection"), { recursive: true });
|
|
|
|
|
await writeJson(path.join(cwd, "src", "app-server", "connection", "compatibility.json"), {
|
|
|
|
|
codexAppServer: {
|
|
|
|
|
typeGeneration: {
|
|
|
|
|
experimental: true,
|
|
|
|
|
},
|
|
|
|
|
initialize: {
|
|
|
|
|
capabilities: {
|
|
|
|
|
experimentalApi: true,
|
|
|
|
|
requestAttestation: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-06-12 09:21:17 +00:00
|
|
|
|
2026-06-26 02:39:55 +00:00
|
|
|
const report = await createApiBaselineReport({
|
|
|
|
|
cwd,
|
|
|
|
|
readCodexVersion: () => "0.139.0",
|
2026-06-12 09:21:17 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-07 13:06:19 +00:00
|
|
|
expect(report.codex.appServerGenerationExperimentalDeclared).toBe(true);
|
2026-06-12 09:21:17 +00:00
|
|
|
expect(report.codex.initializeExperimentalApi).toBe(true);
|
|
|
|
|
expect(report.codex.initializeRequestAttestationDisabled).toBe(true);
|
|
|
|
|
expect(report.failures).toEqual([]);
|
2026-07-10 03:48:36 +00:00
|
|
|
|
|
|
|
|
const recordedOnlyReport = await createApiBaselineReport({
|
|
|
|
|
cwd,
|
|
|
|
|
readCodexVersion: () => null,
|
|
|
|
|
skipLocalCodex: true,
|
|
|
|
|
});
|
|
|
|
|
expect(recordedOnlyReport.codex.localCliCheckSkipped).toBe(true);
|
|
|
|
|
expect(recordedOnlyReport.failures).toEqual([]);
|
2026-06-12 09:21:17 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-26 02:45:25 +00:00
|
|
|
it("reports representative CSS usage policy failures", async () => {
|
2026-06-20 10:15:58 +00:00
|
|
|
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"),
|
2026-06-24 06:54:51 +00:00
|
|
|
"src/component.ts": [
|
2026-06-26 02:45:25 +00:00
|
|
|
'export const className = "codex-panel__used";',
|
2026-06-24 07:09:29 +00:00
|
|
|
"export const dynamicClassName = `codex-panel__task-step--$" + "{status}`;",
|
2026-06-24 06:54:51 +00:00
|
|
|
].join("\n"),
|
2026-06-26 02:45:25 +00:00
|
|
|
"tests/component.test.ts": 'expect("codex-panel__test-only").toBeTruthy();\n',
|
2026-06-24 06:37:18 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-27 13:31:54 +00:00
|
|
|
const result = runNodeScript("scripts/check-css-usage.mjs", [], cwd);
|
2026-06-24 06:37:18 +00:00
|
|
|
|
2026-06-24 06:54:51 +00:00
|
|
|
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--");
|
2026-06-26 02:45:25 +00:00
|
|
|
expect(result.stderr).toContain("codex-panel__test-only");
|
|
|
|
|
expect(result.stderr).toContain("codex-panel__unused");
|
2026-06-24 06:37:18 +00:00
|
|
|
});
|
|
|
|
|
|
2026-07-14 06:53:19 +00:00
|
|
|
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" });
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-30 01:22:32 +00:00
|
|
|
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<string> {
|
2026-07-10 03:50:37 +00:00
|
|
|
const workspace = await mkdtemp(path.join(tmpdir(), "codex-panel-scripts-"));
|
|
|
|
|
tempWorkspaces.add(workspace);
|
|
|
|
|
return workspace;
|
2026-05-30 01:22:32 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 23:18:19 +00:00
|
|
|
async function styleOrderFixture(): Promise<string> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 10:15:58 +00:00
|
|
|
async function cssUsageFixture(files: Record<string, string>): Promise<string> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 01:22:32 +00:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 06:53:19 +00:00
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 01:22:32 +00:00
|
|
|
async function writeJson(file: string, value: unknown): Promise<void> {
|
|
|
|
|
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readJson(file: string): Promise<unknown> {
|
|
|
|
|
return JSON.parse(await readFile(file, "utf8"));
|
|
|
|
|
}
|