Merge branch 'main' into prompt-template-defaults
This commit is contained in:
49
packages/coding-agent/test/changelog.test.ts
Normal file
49
packages/coding-agent/test/changelog.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts";
|
||||
|
||||
const entry: ChangelogEntry = {
|
||||
major: 0,
|
||||
minor: 79,
|
||||
patch: 0,
|
||||
content: "",
|
||||
};
|
||||
|
||||
describe("normalizeChangelogLinks", () => {
|
||||
test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => {
|
||||
const markdown = [
|
||||
"[Project Trust](README.md#project-trust)",
|
||||
"[Extensions](docs/extensions.md#project_trust)",
|
||||
"[Examples](examples/extensions/)",
|
||||
"[Root README](../../README.md#supply-chain-hardening)",
|
||||
].join("\n");
|
||||
|
||||
expect(normalizeChangelogLinks(markdown, entry)).toBe(
|
||||
[
|
||||
"[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)",
|
||||
"[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)",
|
||||
"[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)",
|
||||
"[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("canonicalizes old repository URLs without changing external links", () => {
|
||||
const markdown = [
|
||||
"[#5167](https://github.com/earendil-works/pi-mono/pull/5167)",
|
||||
"[#4163](https://github.com/badlogic/pi-mono/issues/4163)",
|
||||
"[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)",
|
||||
"[External](https://example.com/docs)",
|
||||
"[Local anchor](#settings)",
|
||||
].join("\n");
|
||||
|
||||
expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe(
|
||||
[
|
||||
"[#5167](https://github.com/earendil-works/pi/pull/5167)",
|
||||
"[#4163](https://github.com/earendil-works/pi/issues/4163)",
|
||||
"[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)",
|
||||
"[External](https://example.com/docs)",
|
||||
"[Local anchor](#settings)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ENV_AGENT_DIR } from "../src/config.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRegistry } from "../src/core/model-registry.ts";
|
||||
import { runMigrations } from "../src/migrations.ts";
|
||||
|
||||
describe("config value env var syntax migration", () => {
|
||||
@@ -68,6 +70,23 @@ describe("config value env var syntax migration", () => {
|
||||
expect(logMessage).toContain('auth.json["anthropic"].key: ANTHROPIC_API_KEY -> $ANTHROPIC_API_KEY');
|
||||
});
|
||||
|
||||
it.each([
|
||||
["malformed", '{\n "providers": {\n'],
|
||||
["blank", ""],
|
||||
])("does not throw on %s models.json during config migration", (_name, content) => {
|
||||
const agentDir = createAgentDir();
|
||||
const modelsPath = path.join(agentDir, "models.json");
|
||||
fs.writeFileSync(modelsPath, content, "utf-8");
|
||||
|
||||
withAgentDir(agentDir, () => expect(() => runMigrations(agentDir)).not.toThrow());
|
||||
|
||||
expect(fs.readFileSync(modelsPath, "utf-8")).toBe(content);
|
||||
const registry = ModelRegistry.create(AuthStorage.create(path.join(agentDir, "auth.json")), modelsPath);
|
||||
const loadError = registry.getError();
|
||||
expect(loadError).toContain("Failed to parse models.json");
|
||||
expect(loadError).toContain(`File: ${modelsPath}`);
|
||||
});
|
||||
|
||||
it("rewrites legacy uppercase models.json API key and header values", () => {
|
||||
const agentDir = createAgentDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
44
packages/coding-agent/test/experimental.test.ts
Normal file
44
packages/coding-agent/test/experimental.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { areExperimentalFeaturesEnabled } from "../src/core/experimental.ts";
|
||||
|
||||
describe("areExperimentalFeaturesEnabled", () => {
|
||||
const originalPiExperimental = process.env.PI_EXPERIMENTAL;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPiExperimental === undefined) {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
} else {
|
||||
process.env.PI_EXPERIMENTAL = originalPiExperimental;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is unset", () => {
|
||||
delete process.env.PI_EXPERIMENTAL;
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is empty", () => {
|
||||
process.env.PI_EXPERIMENTAL = "";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when PI_EXPERIMENTAL is set to 1", () => {
|
||||
process.env.PI_EXPERIMENTAL = "1";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is set to 0", () => {
|
||||
process.env.PI_EXPERIMENTAL = "0";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when PI_EXPERIMENTAL is set to a non-1 value", () => {
|
||||
process.env.PI_EXPERIMENTAL = "true";
|
||||
|
||||
expect(areExperimentalFeaturesEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,7 @@ describe("ExtensionRunner", () => {
|
||||
const extensionContextActions: ExtensionContextActions = {
|
||||
getModel: () => undefined,
|
||||
isIdle: () => true,
|
||||
isProjectTrusted: () => true,
|
||||
getSignal: () => undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
@@ -496,6 +497,18 @@ describe("ExtensionRunner", () => {
|
||||
expect(ctx.hasUI).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes project trust state on ExtensionContext", async () => {
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
runner.bindCore(extensionActions, {
|
||||
...extensionContextActions,
|
||||
isProjectTrusted: () => false,
|
||||
});
|
||||
|
||||
const ctx = runner.createContext();
|
||||
expect(ctx.isProjectTrusted()).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes rpc mode with hasUI true when an RPC UI context is provided", async () => {
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
|
||||
@@ -324,6 +324,36 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
|
||||
expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true);
|
||||
expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]);
|
||||
});
|
||||
|
||||
test("merges triggerCharacters from wrapper factories", () => {
|
||||
const defaultEditor = { setAutocompleteProvider: vi.fn() };
|
||||
const customEditor = { setAutocompleteProvider: vi.fn() };
|
||||
const passThrough =
|
||||
(triggerCharacters: string[]): AutocompleteProviderFactory =>
|
||||
(current) => ({
|
||||
triggerCharacters,
|
||||
getSuggestions: (lines, cursorLine, cursorCol, options) =>
|
||||
current.getSuggestions(lines, cursorLine, cursorCol, options),
|
||||
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) =>
|
||||
current.applyCompletion(lines, cursorLine, cursorCol, item, prefix),
|
||||
});
|
||||
|
||||
const fakeThis = {
|
||||
createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined),
|
||||
defaultEditor,
|
||||
editor: customEditor,
|
||||
autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])],
|
||||
};
|
||||
|
||||
(
|
||||
InteractiveMode as unknown as {
|
||||
prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void };
|
||||
}
|
||||
).prototype.setupAutocompleteProvider.call(fakeThis);
|
||||
|
||||
const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider;
|
||||
expect(provider.triggerCharacters).toEqual(["$", "!"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InteractiveMode.showLoadedResources", () => {
|
||||
|
||||
@@ -157,6 +157,70 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses default project trust for list", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["list"])).resolves.toBeUndefined();
|
||||
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stdout).toContain("Project packages:");
|
||||
expect(stdout).toContain("npm:@project/pkg");
|
||||
expect(stdout).not.toContain("No packages installed.");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses project_trust extensions for package commands", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
main(["list"], {
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("project_trust", () => ({ trusted: "yes" }));
|
||||
},
|
||||
],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stdout).toContain("Project packages:");
|
||||
expect(stdout).toContain("npm:@project/pkg");
|
||||
expect(stdout).not.toContain("No packages installed.");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets trust.json override default project trust", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] }));
|
||||
new ProjectTrustStore(agentDir).set(projectDir, false);
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(main(["list"])).resolves.toBeUndefined();
|
||||
|
||||
const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stdout).toContain("No packages installed.");
|
||||
expect(stdout).not.toContain("Project packages:");
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks local package changes when project is untrusted", async () => {
|
||||
mkdirSync(join(projectDir, ".pi"), { recursive: true });
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
@@ -376,7 +376,7 @@ Content`,
|
||||
expect(loader.getSystemPrompt()).toBe("You are a helpful assistant.");
|
||||
});
|
||||
|
||||
it("should skip project resources when project is not trusted", async () => {
|
||||
it("should skip trust-gated project resources when project is not trusted", async () => {
|
||||
const piDir = join(cwd, ".pi");
|
||||
const extensionsDir = join(piDir, "extensions");
|
||||
const skillDir = join(piDir, "skills", "project-skill");
|
||||
@@ -414,7 +414,7 @@ Project skill content`,
|
||||
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(agentDir, "AGENTS.md"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(false);
|
||||
expect(loader.getAgentsFiles().agentsFiles.some((file) => file.path === join(cwd, "AGENTS.md"))).toBe(true);
|
||||
expect(loader.getExtensions().extensions).toHaveLength(0);
|
||||
expect(loader.getExtensions().errors).toEqual([]);
|
||||
expect(loader.getSkills().skills.some((skill) => skill.name === "project-skill")).toBe(false);
|
||||
|
||||
@@ -250,6 +250,23 @@ describe("SettingsManager", () => {
|
||||
expect(manager.getProjectSettings()).toEqual({});
|
||||
expect(JSON.parse(readFileSync(projectSettingsPath, "utf-8"))).toEqual({ packages: ["npm:existing"] });
|
||||
});
|
||||
|
||||
it("should read default project trust from global settings only", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "always" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ defaultProjectTrust: "never" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getDefaultProjectTrust()).toBe("always");
|
||||
});
|
||||
|
||||
it("should default invalid project trust settings to ask", () => {
|
||||
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ defaultProjectTrust: "sometimes" }));
|
||||
|
||||
const manager = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
expect(manager.getDefaultProjectTrust()).toBe("ask");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project settings directory creation", () => {
|
||||
|
||||
@@ -80,6 +80,22 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string;
|
||||
}
|
||||
|
||||
describe("stdout cleanliness in non-interactive modes", () => {
|
||||
it("prints --version to stdout when stdout is redirected", async () => {
|
||||
const result = await runCli(["--version"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
it("prints plain --help to stdout when stdout is redirected", async () => {
|
||||
const result = await runCli(["--help"]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("Usage:");
|
||||
expect(result.stderr).not.toContain("Usage:");
|
||||
});
|
||||
|
||||
it("keeps stdout empty for --mode json --help while routing trusted startup chatter to stderr", async () => {
|
||||
const result = await runCli(["--mode", "json", "--help", "--approve"]);
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
|
||||
import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
|
||||
import { LoginDialogComponent } from "../../../src/modes/interactive/components/login-dialog.ts";
|
||||
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../../../src/utils/ansi.ts";
|
||||
|
||||
vi.mock("../../../src/utils/open-browser.ts", () => ({
|
||||
openBrowser: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDialog(): LoginDialogComponent {
|
||||
return new LoginDialogComponent(
|
||||
{ requestRender: vi.fn() } as unknown as TUI,
|
||||
"prompt-repro",
|
||||
() => {},
|
||||
"Prompt Repro",
|
||||
);
|
||||
}
|
||||
|
||||
function renderDialog(dialog: LoginDialogComponent): string[] {
|
||||
return stripAnsi(dialog.render(120).join("\n"))
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd());
|
||||
}
|
||||
|
||||
function countRenderedValue(lines: string[], value: string): number {
|
||||
return lines.filter((line) => line.trim() === `> ${value}`).length;
|
||||
}
|
||||
|
||||
describe("LoginDialogComponent OAuth prompts", () => {
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
test("keeps previous prompt input stable when a later prompt is active", async () => {
|
||||
const dialog = createDialog();
|
||||
|
||||
const firstPrompt = dialog.showPrompt("First prompt:", "first-value");
|
||||
dialog.handleInput("first-value");
|
||||
dialog.handleInput("\n");
|
||||
await expect(firstPrompt).resolves.toBe("first-value");
|
||||
|
||||
const secondPrompt = dialog.showPrompt("Second prompt:");
|
||||
dialog.handleInput("second-secret-demo");
|
||||
|
||||
const lines = renderDialog(dialog);
|
||||
expect(lines.join("\n")).toContain("First prompt:");
|
||||
expect(lines.join("\n")).toContain("Second prompt:");
|
||||
expect(countRenderedValue(lines, "first-value")).toBe(1);
|
||||
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
|
||||
|
||||
dialog.handleInput("\n");
|
||||
await expect(secondPrompt).resolves.toBe("second-secret-demo");
|
||||
});
|
||||
|
||||
test("preserves auth instructions when showing a prompt", () => {
|
||||
const dialog = createDialog();
|
||||
|
||||
dialog.showAuth("https://example.invalid/login", "Authorize the extension");
|
||||
dialog.showPrompt("First prompt:");
|
||||
|
||||
const output = renderDialog(dialog).join("\n");
|
||||
expect(output).toContain("https://example.invalid/login");
|
||||
expect(output).toContain("Authorize the extension");
|
||||
expect(output).toContain("First prompt:");
|
||||
});
|
||||
|
||||
test("keeps previous manual input stable when a later prompt is active", async () => {
|
||||
const dialog = createDialog();
|
||||
|
||||
const manualInput = dialog.showManualInput("Paste callback URL:");
|
||||
dialog.handleInput("callback-value");
|
||||
dialog.handleInput("\n");
|
||||
await expect(manualInput).resolves.toBe("callback-value");
|
||||
|
||||
const prompt = dialog.showPrompt("Second prompt:");
|
||||
dialog.handleInput("second-secret-demo");
|
||||
|
||||
const lines = renderDialog(dialog);
|
||||
expect(lines.join("\n")).toContain("Paste callback URL:");
|
||||
expect(lines.join("\n")).toContain("Second prompt:");
|
||||
expect(countRenderedValue(lines, "callback-value")).toBe(1);
|
||||
expect(countRenderedValue(lines, "second-secret-demo")).toBe(1);
|
||||
|
||||
dialog.handleInput("\n");
|
||||
await expect(prompt).resolves.toBe("second-secret-demo");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte
|
||||
modelRegistry: {} as ExtensionContext["modelRegistry"],
|
||||
model: undefined,
|
||||
isIdle: () => true,
|
||||
isProjectTrusted: () => true,
|
||||
signal: undefined,
|
||||
abort: vi.fn(),
|
||||
hasPendingMessages: () => false,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../src/core/trust-manager.ts";
|
||||
import {
|
||||
getProjectTrustPath,
|
||||
hasProjectConfigDir,
|
||||
hasProjectTrustInputs,
|
||||
ProjectTrustStore,
|
||||
} from "../src/core/trust-manager.ts";
|
||||
|
||||
describe("ProjectTrustStore", () => {
|
||||
let tempDir: string;
|
||||
@@ -25,12 +30,52 @@ describe("ProjectTrustStore", () => {
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
|
||||
expect(store.get(cwd)).toBeNull();
|
||||
expect(store.getEntry(cwd)).toBeNull();
|
||||
store.set(cwd, true);
|
||||
expect(store.get(cwd)).toBe(true);
|
||||
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: true });
|
||||
store.set(cwd, false);
|
||||
expect(store.get(cwd)).toBe(false);
|
||||
expect(store.getEntry(cwd)).toEqual({ path: getProjectTrustPath(cwd), decision: false });
|
||||
store.set(cwd, null);
|
||||
expect(store.get(cwd)).toBeNull();
|
||||
expect(store.getEntry(cwd)).toBeNull();
|
||||
});
|
||||
|
||||
it("inherits the closest saved decision from parent directories", () => {
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
const parentDir = join(tempDir, "trusted-parent");
|
||||
const childDir = join(parentDir, "project");
|
||||
const grandchildDir = join(childDir, "nested");
|
||||
mkdirSync(grandchildDir, { recursive: true });
|
||||
|
||||
store.set(parentDir, true);
|
||||
expect(store.get(childDir)).toBe(true);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
|
||||
expect(store.get(grandchildDir)).toBe(true);
|
||||
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
|
||||
|
||||
store.set(childDir, false);
|
||||
expect(store.get(grandchildDir)).toBe(false);
|
||||
expect(store.getEntry(grandchildDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
|
||||
});
|
||||
|
||||
it("can clear a child override to inherit parent trust", () => {
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
const parentDir = join(tempDir, "trusted-parent");
|
||||
const childDir = join(parentDir, "project");
|
||||
mkdirSync(childDir, { recursive: true });
|
||||
|
||||
store.set(parentDir, true);
|
||||
store.set(childDir, false);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(childDir), decision: false });
|
||||
|
||||
store.setMany([
|
||||
{ path: parentDir, decision: true },
|
||||
{ path: childDir, decision: null },
|
||||
]);
|
||||
expect(store.get(childDir)).toBe(true);
|
||||
expect(store.getEntry(childDir)).toEqual({ path: getProjectTrustPath(parentDir), decision: true });
|
||||
});
|
||||
|
||||
it("fails loudly without overwriting malformed trust stores", () => {
|
||||
@@ -53,9 +98,13 @@ describe("ProjectTrustStore", () => {
|
||||
rmSync(join(cwd, ".pi"), { recursive: true, force: true });
|
||||
|
||||
writeFileSync(join(cwd, "AGENTS.md"), "Project instructions");
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(true);
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
rmSync(join(cwd, "AGENTS.md"), { force: true });
|
||||
|
||||
writeFileSync(join(cwd, "CLAUDE.md"), "Legacy project instructions");
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(false);
|
||||
rmSync(join(cwd, "CLAUDE.md"), { force: true });
|
||||
|
||||
mkdirSync(join(cwd, ".agents", "skills"), { recursive: true });
|
||||
expect(hasProjectTrustInputs(cwd)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("TrustSelectorComponent", () => {
|
||||
it("marks the saved trusted decision", () => {
|
||||
const selector = new TrustSelectorComponent({
|
||||
cwd: "/project",
|
||||
savedDecision: true,
|
||||
savedDecision: { path: "/project", decision: true },
|
||||
projectTrusted: true,
|
||||
onSelect: () => {},
|
||||
onCancel: () => {},
|
||||
@@ -25,7 +25,7 @@ describe("TrustSelectorComponent", () => {
|
||||
|
||||
const output = stripAnsi(selector.render(120).join("\n"));
|
||||
|
||||
expect(output).toContain("Saved decision: trusted");
|
||||
expect(output).toContain("Saved decision: trusted (/project)");
|
||||
expect(output).toContain("Current session: trusted");
|
||||
expect(output).toContain("Trust ✓");
|
||||
expect(output).not.toContain("Do not trust ✓");
|
||||
@@ -43,6 +43,45 @@ describe("TrustSelectorComponent", () => {
|
||||
|
||||
selector.handleInput("\n");
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(true);
|
||||
expect(onSelect).toHaveBeenCalledWith({ trusted: true, updates: [{ path: "/project", decision: true }] });
|
||||
});
|
||||
|
||||
it("labels saved ancestor decisions as inherited", () => {
|
||||
const selector = new TrustSelectorComponent({
|
||||
cwd: "/parent/project/nested",
|
||||
savedDecision: { path: "/parent", decision: true },
|
||||
projectTrusted: true,
|
||||
onSelect: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
|
||||
const output = stripAnsi(selector.render(120).join("\n"));
|
||||
|
||||
expect(output).toContain("Saved decision: trusted (inherited from /parent)");
|
||||
});
|
||||
|
||||
it("adds a trust parent option", () => {
|
||||
const onSelect = vi.fn();
|
||||
const selector = new TrustSelectorComponent({
|
||||
cwd: "/parent/project",
|
||||
savedDecision: { path: "/parent", decision: true },
|
||||
projectTrusted: true,
|
||||
onSelect,
|
||||
onCancel: () => {},
|
||||
});
|
||||
|
||||
const output = stripAnsi(selector.render(120).join("\n"));
|
||||
expect(output).toContain("Saved decision: trusted (inherited from /parent)");
|
||||
expect(output).toContain("Trust parent folder (/parent) ✓");
|
||||
|
||||
selector.handleInput("\n");
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
trusted: true,
|
||||
updates: [
|
||||
{ path: "/parent", decision: true },
|
||||
{ path: "/parent/project", decision: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user