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
This commit is contained in:
326
third_party/pi/packages/coding-agent/test/config-schemas.test.ts
vendored
Normal file
326
third_party/pi/packages/coding-agent/test/config-schemas.test.ts
vendored
Normal file
@@ -0,0 +1,326 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user