Files
dev/third_party/pi/packages/coding-agent/test/config-schemas.test.ts
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

327 lines
12 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Compile } from "typebox/compile";
import { afterEach, describe, expect, it } from "vitest";
import { renderConfigSchemas } from "../scripts/generate-schemas.ts";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import { ModelConfig } from "../src/core/model-config.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { SettingsSchema } from "../src/core/settings-schema.ts";
import { validateThemeJson } from "../src/modes/interactive/theme/theme-schema.ts";
const schemaBaseUrl = "https://pi.dev/schemas";
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createTemporaryDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), "pi-config-schema-test-"));
temporaryDirectories.push(directory);
return directory;
}
function schemaUrl(name: "models" | "settings" | "keybindings" | "theme"): string {
return `${schemaBaseUrl}/${name}.schema.json`;
}
describe("generated configuration schemas", () => {
it("matches the four committed artifacts", () => {
const rendered = renderConfigSchemas();
expect([...rendered.keys()]).toEqual([
"schemas/models.schema.json",
"schemas/settings.schema.json",
"schemas/keybindings.schema.json",
"schemas/theme.schema.json",
]);
for (const [relativePath, expected] of rendered) {
expect(
readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf-8"),
`${relativePath} is stale; run npm run generate:schemas`,
).toBe(expected);
}
});
it("preserves editor guidance in the generated theme schema", () => {
const schema = JSON.parse(renderConfigSchemas().get("schemas/theme.schema.json") ?? "");
const accent = schema.properties.colors.properties.accent;
const colorValue = accent.$ref ? schema.$defs.ColorValue : accent;
expect(schema).toMatchObject({
title: "Pi Coding Agent Theme",
description: "Theme schema for Pi coding agent",
properties: {
colors: {
description: expect.stringContaining("use compatible fallbacks"),
properties: {
scrollbarTrack: { description: expect.stringContaining("falls back to muted") },
accent: { description: "Primary accent color (logo, selected items, cursor)" },
},
},
export: { description: expect.stringContaining("defaults derived from userMessageBg") },
},
});
expect(colorValue).toMatchObject({
anyOf: [{ description: expect.stringContaining("Hex color") }, expect.any(Object)],
});
});
it("preserves moved model and settings guidance", () => {
const models = JSON.parse(renderConfigSchemas().get("schemas/models.schema.json") ?? "");
expect(models.$defs.ModelCost.properties).toMatchObject({
input: { description: expect.stringContaining("USD per million tokens") },
tiers: { description: expect.stringContaining("highest matching input threshold") },
});
expect(models.$defs.ModelInputLimits.properties).toMatchObject({
maxRequestBytes: { description: expect.stringContaining("serialized provider request size") },
});
const settings = JSON.parse(renderConfigSchemas().get("schemas/settings.schema.json") ?? "");
expect(settings.properties).toMatchObject({
quietStartup: { description: expect.stringContaining("hide all startup output") },
defaultTools: { description: expect.stringContaining("+name and -name") },
fullscreenWheelScrollLines: { description: expect.stringContaining("1 to 100") },
});
});
it.each([
{
name: "models.json",
artifact: "schemas/models.schema.json",
valid: {
providers: {
local: {
compat: {
zaiToolStream: true,
thinkingTokenBudgetField: "thinking_budget",
chatTemplateKwargs: { budget: { $var: "thinking.budget" } },
supportsMidConvoSystemMessages: true,
},
models: [
{
id: "model",
inputLimits: { maxRequestBytes: 1024, images: { maxPerRequest: 2 } },
promptCache: { short: 300 },
},
],
},
},
},
invalid: { providers: { local: { models: [{ id: "" }] } } },
},
{
name: "settings.json",
artifact: "schemas/settings.schema.json",
valid: {
theme: "dark",
cacheWarming: "idle",
extensionSetting: { enabled: true },
queueMode: "all",
websockets: true,
skills: { enableSkillCommands: true, customDirectories: ["./skills"] },
retry: { maxDelayMs: 1000 },
},
invalid: { cacheWarming: "always" },
},
{
name: "keybindings.json",
artifact: "schemas/keybindings.schema.json",
valid: { "app.session.new": "ctrl+n", "extension.action": ["alt+x"] },
invalid: { "app.session.new": 42 },
},
])("validates representative $name documents", ({ artifact, valid, invalid }) => {
const schema = JSON.parse(renderConfigSchemas().get(artifact) ?? "");
const validator = Compile(schema);
expect(validator.Check(valid)).toBe(true);
expect(validator.Check(invalid)).toBe(false);
});
it("rejects non-positive model token limits", () => {
const schema = JSON.parse(renderConfigSchemas().get("schemas/models.schema.json") ?? "");
const validator = Compile(schema);
for (const field of ["contextWindow", "maxTokens"] as const) {
for (const value of [0, -1]) {
expect(
validator.Check({ providers: { local: { models: [{ id: "model", [field]: value }] } } }),
`models[].${field}=${value}`,
).toBe(false);
expect(
validator.Check({ providers: { local: { modelOverrides: { model: { [field]: value } } } } }),
`modelOverrides.${field}=${value}`,
).toBe(false);
}
}
expect(
validator.Check({
providers: {
local: {
models: [{ id: "model", contextWindow: 1, maxTokens: 1 }],
modelOverrides: { model: { contextWindow: 1, maxTokens: 1 } },
},
},
}),
).toBe(true);
});
it("validates keybinding syntax", () => {
const schema = JSON.parse(renderConfigSchemas().get("schemas/keybindings.schema.json") ?? "");
const validator = Compile(schema);
for (const binding of ["a", "9", "pageUp", "+", "ctrl+shift+x", "alt+ctrl+?", "ctrl+shift+alt+super+f12"]) {
expect(validator.Check({ "extension.action": binding }), binding).toBe(true);
}
for (const binding of [
"",
"Ctrl+x",
"control+x",
"ctrl+not-a-key",
"ctrl+ctrl+x",
"ctrl+shift+alt+super+ctrl+x",
]) {
expect(validator.Check({ "extension.action": binding }), binding).toBe(false);
}
});
it("rejects settings values that runtime accessors reject", () => {
const validator = Compile(SettingsSchema);
for (const invalid of [
{ compaction: { reserveTokens: -1 } },
{ compaction: { keepRecentTokens: 1.5 } },
{
compaction: {
modelOverrides: { "provider/model": { reserveTokens: Number.MAX_SAFE_INTEGER + 1 } },
},
},
{ httpIdleTimeoutMs: -1 },
{ websocketConnectTimeoutMs: -1 },
{ httpIdleTimeoutMs: "bogus" },
{ websocketConnectTimeoutMs: "30000" },
{ codemode: { inlineBudget: -1 } },
]) {
expect(validator.Check(invalid)).toBe(false);
}
expect(
validator.Check({
compaction: {
reserveTokens: 0,
keepRecentTokens: Number.MAX_SAFE_INTEGER,
modelOverrides: { "provider/model": { reserveTokens: 0 } },
},
httpIdleTimeoutMs: 0,
websocketConnectTimeoutMs: "disabled",
codemode: { inlineBudget: 0 },
}),
).toBe(true);
});
it("describes legacy settings migrations accurately", () => {
const schema = JSON.parse(renderConfigSchemas().get("schemas/settings.schema.json") ?? "") as {
properties: {
queueMode: { description: string };
retry: { properties: { maxDelayMs: { description: string } } };
};
};
expect(schema.properties.retry.properties.maxDelayMs.description).toContain("provider.maxRetryDelayMs");
expect(schema.properties.queueMode.description).toBe("Legacy setting migrated to steeringMode.");
});
it("accepts custom compatibility settings and rejects malformed known fields", async () => {
const directory = createTemporaryDirectory();
const path = join(directory, "models.json");
const compat = {
customOption: "value",
supportsStore: true,
supportsAdditionalTools: true,
};
writeFileSync(path, JSON.stringify({ providers: { demo: { api: "custom-api", compat } } }));
const config = await ModelConfig.load(path);
expect(config.getError()).toBeUndefined();
expect(config.getProvider("demo")?.compat).toEqual(compat);
writeFileSync(path, JSON.stringify({ providers: { demo: { compat: { supportsLongCacheRetention: "yes" } } } }));
expect((await ModelConfig.load(path)).getError()).toContain("Invalid models.json schema");
});
it("validates $schema in models.json at runtime", async () => {
const directory = createTemporaryDirectory();
const path = join(directory, "models.json");
writeFileSync(
path,
JSON.stringify({
$schema: schemaUrl("models"),
providers: { demo: { baseUrl: "http://localhost:8080/v1", models: [{ id: "demo" }] } },
}),
);
const config = await ModelConfig.load(path);
expect(config.getError()).toBeUndefined();
expect(config.getProvider("demo")?.models?.[0]?.id).toBe("demo");
writeFileSync(
path,
JSON.stringify({ $schema: schemaUrl("models"), providers: { demo: { models: [{ id: "" }] } } }),
);
expect((await ModelConfig.load(path)).getError()).toContain("Invalid models.json schema");
writeFileSync(path, JSON.stringify({ $schema: 42, providers: {} }));
expect((await ModelConfig.load(path)).getError()).toContain("$schema");
});
it("accepts $schema in settings.json and preserves unknown settings", () => {
const directory = createTemporaryDirectory();
writeFileSync(
join(directory, "settings.json"),
JSON.stringify({ $schema: schemaUrl("settings"), theme: "light", extensionSetting: { enabled: true } }),
);
const manager = SettingsManager.create(directory, directory);
expect(manager.getTheme()).toBe("light");
expect(manager.getGlobalSettings()).toMatchObject({
$schema: schemaUrl("settings"),
extensionSetting: { enabled: true },
});
});
it("accepts $schema in keybindings.json without treating it as an action", () => {
const directory = createTemporaryDirectory();
writeFileSync(
join(directory, "keybindings.json"),
JSON.stringify({
$schema: schemaUrl("keybindings"),
"app.session.new": "ctrl+n",
"extension.invalid": "ctrl+not-a-key",
}),
);
const manager = KeybindingsManager.create(directory);
expect(manager.getUserBindings()).toEqual({ "app.session.new": "ctrl+n" });
expect(manager.getEffectiveConfig()["app.session.new"]).toBe("ctrl+n");
});
it("accepts $schema in themes and rejects malformed themes", () => {
const builtIn = JSON.parse(
readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf-8"),
) as Record<string, unknown>;
expect(validateThemeJson("custom", { ...builtIn, $schema: schemaUrl("theme"), name: "custom" }).name).toBe(
"custom",
);
expect(() => validateThemeJson("invalid", { ...builtIn, name: "invalid/name" })).toThrow(
"theme names cannot contain",
);
expect(() => validateThemeJson("invalid", { ...builtIn, colors: {} })).toThrow("Missing required color tokens");
for (const invalid of [
{ ...builtIn, unexpected: true },
{
...builtIn,
colors: { ...(builtIn.colors as Record<string, unknown>), scrollbarThmb: "" },
},
{ ...builtIn, export: { unexpected: "" } },
]) {
expect(() => validateThemeJson("invalid", invalid)).toThrow(/additional properties/i);
}
});
});