Archived
test: audit existing suite
This commit is contained in:
@@ -80,15 +80,27 @@ describe("ViewportPositionRepairer", () => {
|
|||||||
const timer = firstMapEntry(scheduler.timers);
|
const timer = firstMapEntry(scheduler.timers);
|
||||||
expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS);
|
expect(timer[1].delayMs).toBe(VIEWPORT_POSITION_REPAIR_DELAY_MS);
|
||||||
|
|
||||||
|
scheduler.documentElement.scrollTop = 56;
|
||||||
|
scheduler.body.scrollTop = 78;
|
||||||
scheduler.runAnimationFrame(firstFrame);
|
scheduler.runAnimationFrame(firstFrame);
|
||||||
expect(scheduler.scrollCalls).toHaveLength(2);
|
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0]]);
|
||||||
|
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||||
|
expect(scheduler.body.scrollTop).toBe(0);
|
||||||
const secondFrame = firstMapKey(scheduler.animationFrames);
|
const secondFrame = firstMapKey(scheduler.animationFrames);
|
||||||
|
|
||||||
|
scheduler.documentElement.scrollTop = 90;
|
||||||
|
scheduler.body.scrollTop = 123;
|
||||||
scheduler.runAnimationFrame(secondFrame);
|
scheduler.runAnimationFrame(secondFrame);
|
||||||
expect(scheduler.scrollCalls).toHaveLength(3);
|
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0]]);
|
||||||
|
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||||
|
expect(scheduler.body.scrollTop).toBe(0);
|
||||||
|
|
||||||
|
scheduler.documentElement.scrollTop = 34;
|
||||||
|
scheduler.body.scrollTop = 12;
|
||||||
scheduler.runTimer(timer[0]);
|
scheduler.runTimer(timer[0]);
|
||||||
expect(scheduler.scrollCalls).toHaveLength(4);
|
expect(scheduler.scrollCalls).toEqual([[0, 0], [0, 0], [0, 0], [0, 0]]);
|
||||||
|
expect(scheduler.documentElement.scrollTop).toBe(0);
|
||||||
|
expect(scheduler.body.scrollTop).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces pending scheduled repairs", () => {
|
it("replaces pending scheduled repairs", () => {
|
||||||
@@ -102,6 +114,10 @@ describe("ViewportPositionRepairer", () => {
|
|||||||
|
|
||||||
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
||||||
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
||||||
|
expect(scheduler.animationFrames.has(firstFrame)).toBe(false);
|
||||||
|
expect(scheduler.timers.has(firstTimer)).toBe(false);
|
||||||
|
expect(scheduler.animationFrames.size).toBe(1);
|
||||||
|
expect(scheduler.timers.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears pending work when repair is no longer needed", () => {
|
it("clears pending work when repair is no longer needed", () => {
|
||||||
@@ -115,5 +131,7 @@ describe("ViewportPositionRepairer", () => {
|
|||||||
|
|
||||||
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
expect(scheduler.canceledAnimationFrames).toEqual([firstFrame]);
|
||||||
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
expect(scheduler.clearedTimers).toEqual([firstTimer]);
|
||||||
|
expect(scheduler.animationFrames.size).toBe(0);
|
||||||
|
expect(scheduler.timers.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ describe("cached new sessions", () => {
|
|||||||
rememberCachedNewSession(baseSession, "local", storage);
|
rememberCachedNewSession(baseSession, "local", storage);
|
||||||
rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage);
|
rememberCachedNewSession({ ...baseSession, id: "other", cwd: "/other" }, "local", storage);
|
||||||
|
|
||||||
expect(mergeCachedNewSessions("/repo", [], "local", storage).map((session) => session.id)).toEqual(["session-1"]);
|
const cachedOnly = mergeCachedNewSessions("/repo", [], "local", storage);
|
||||||
expect(mergeCachedNewSessions("/repo", [baseSession], "local", storage).map((session) => session.id)).toEqual(["session-1"]);
|
const mergedWithServerSession = mergeCachedNewSessions("/repo", [baseSession], "local", storage);
|
||||||
expect(isCachedNewSessionInfo(mergeCachedNewSessions("/repo", [baseSession], "local", storage)[0])).toBe(false);
|
|
||||||
|
expect(cachedOnly.map((session) => session.id)).toEqual(["session-1"]);
|
||||||
|
expect(mergedWithServerSession.map((session) => session.id)).toEqual(["session-1"]);
|
||||||
|
expect(isCachedNewSessionInfo(mergedWithServerSession[0])).toBe(false);
|
||||||
expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]);
|
expect(loadCachedNewSessions(storage).map((session) => session.id)).toEqual(["other"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ describe("ChatScrollController", () => {
|
|||||||
expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" });
|
expect(JSON.parse(storage.getItem(key) ?? "{}")).toEqual({ mode: "bottom" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("captures the session id when scheduling a delayed save", () => {
|
it("cancels the previous delayed save and passes the latest session id", () => {
|
||||||
const scheduler = new ManualScheduler();
|
const scheduler = new ManualScheduler();
|
||||||
const controller = new ChatScrollController(new MemoryScrollStorage(), scheduler);
|
const controller = new ChatScrollController(new MemoryScrollStorage(), scheduler);
|
||||||
const saved: string[] = [];
|
const saved: string[] = [];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe("selectable row activation", () => {
|
|||||||
expect(action).toHaveBeenCalledOnce();
|
expect(action).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves contributed links and other interactive elements", () => {
|
it("preserves contributed links inside rows", () => {
|
||||||
const action = vi.fn();
|
const action = vi.fn();
|
||||||
activateSelectableRow(eventWithPath(matchTarget((selector: string) => selector.includes("a[href]"))), action);
|
activateSelectableRow(eventWithPath(matchTarget((selector: string) => selector.includes("a[href]"))), action);
|
||||||
expect(action).not.toHaveBeenCalled();
|
expect(action).not.toHaveBeenCalled();
|
||||||
@@ -57,6 +57,8 @@ describe("selectable row activation", () => {
|
|||||||
expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true);
|
expect(handleSelectableRowKeyboard(event, { activate: vi.fn(), cancel })).toBe(true);
|
||||||
|
|
||||||
expect(cancel).toHaveBeenCalledOnce();
|
expect(cancel).toHaveBeenCalledOnce();
|
||||||
|
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(event.stopPropagation).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -123,25 +123,6 @@ describe("settings-general-panel save payloads", () => {
|
|||||||
expect(getPanelProperty(panel, "machineLocalError")).toBe("");
|
expect(getPanelProperty(panel, "machineLocalError")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears upload defaults with a selected-machine-safe patch", async () => {
|
|
||||||
const panel = new SettingsGeneralPanel();
|
|
||||||
const onSaveMachineConfig = vi.fn();
|
|
||||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
|
||||||
setPanelProperty(panel, "machineDraft", {
|
|
||||||
allowedPathsText: "",
|
|
||||||
uploadDefaultFolder: "",
|
|
||||||
} satisfies MachineAccessConfigDraft);
|
|
||||||
|
|
||||||
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
|
|
||||||
|
|
||||||
expect(onSaveMachineConfig.mock.calls).toEqual([[
|
|
||||||
{
|
|
||||||
pathAccess: { allowedPaths: [] },
|
|
||||||
uploads: {},
|
|
||||||
},
|
|
||||||
]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps invalid upload folders local and does not save selected-machine config", async () => {
|
it("keeps invalid upload folders local and does not save selected-machine config", async () => {
|
||||||
const panel = new SettingsGeneralPanel();
|
const panel = new SettingsGeneralPanel();
|
||||||
const onSaveMachineConfig = vi.fn();
|
const onSaveMachineConfig = vi.fn();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe("settings-packages-panel layout", () => {
|
|||||||
expect(rendered).not.toContain("Pi package list unavailable");
|
expect(rendered).not.toContain("Pi package list unavailable");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders package load errors before the trusted-code warning while preserving loaded data", () => {
|
it("orders package errors before the trusted-code warning while preserving loaded data", () => {
|
||||||
const panel = new SettingsPackagesPanel();
|
const panel = new SettingsPackagesPanel();
|
||||||
panel.targetMachine = remoteTarget;
|
panel.targetMachine = remoteTarget;
|
||||||
panel.packagesResponse = { packages: [packageInfo("npm:@acme/tools")] };
|
panel.packagesResponse = { packages: [packageInfo("npm:@acme/tools")] };
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("settings-panel-frame", () => {
|
|||||||
expect(rendered.indexOf('class="notice-stack"')).toBeLessThan(rendered.indexOf('class="content"'));
|
expect(rendered.indexOf('class="notice-stack"')).toBeLessThan(rendered.indexOf('class="content"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps notice types to consistent default tones and roles", () => {
|
it("maps representative notice types to consistent default tones and roles", () => {
|
||||||
const notices: readonly SettingsNotice[] = [
|
const notices: readonly SettingsNotice[] = [
|
||||||
{ type: "availability", content: "Configuration unavailable." },
|
{ type: "availability", content: "Configuration unavailable." },
|
||||||
{ type: "success", content: "Saved." },
|
{ type: "success", content: "Saved." },
|
||||||
@@ -41,7 +41,12 @@ describe("settings-panel-frame", () => {
|
|||||||
const values = collectTemplateValues(frame.render());
|
const values = collectTemplateValues(frame.render());
|
||||||
|
|
||||||
expect(notices.map(settingsNoticeTone)).toEqual(["error", "success", "warning", "info"]);
|
expect(notices.map(settingsNoticeTone)).toEqual(["error", "success", "warning", "info"]);
|
||||||
expect(values).toEqual(expect.arrayContaining(["notice error", "alert", "notice success", "status", "notice warning", "note", "notice info"]));
|
expect(values.filter(isNoticeClassOrRole)).toEqual([
|
||||||
|
"notice error", "alert",
|
||||||
|
"notice success", "status",
|
||||||
|
"notice warning", "note",
|
||||||
|
"notice info", "note",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wires the default header action through the frame", () => {
|
it("wires the default header action through the frame", () => {
|
||||||
@@ -145,3 +150,8 @@ function isStringArray(value: unknown): value is string[] {
|
|||||||
function isActionHandler(value: unknown): value is () => void {
|
function isActionHandler(value: unknown): value is () => void {
|
||||||
return typeof value === "function";
|
return typeof value === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNoticeClassOrRole(value: unknown): value is string {
|
||||||
|
return typeof value === "string"
|
||||||
|
&& (value.startsWith("notice ") || value === "alert" || value === "status" || value === "note");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
configFromDraft,
|
|
||||||
draftFromConfig,
|
|
||||||
gatewayServerConfigFromDraft,
|
gatewayServerConfigFromDraft,
|
||||||
gatewayServerDraftFromConfig,
|
gatewayServerDraftFromConfig,
|
||||||
machineAccessConfigPatchFromDraft,
|
machineAccessConfigPatchFromDraft,
|
||||||
@@ -28,28 +26,42 @@ describe("settings config drafts", () => {
|
|||||||
allowedPathsText: "/tmp\n~/SDKs",
|
allowedPathsText: "/tmp\n~/SDKs",
|
||||||
uploadDefaultFolder: "manual/uploads",
|
uploadDefaultFolder: "manual/uploads",
|
||||||
});
|
});
|
||||||
|
expect(gatewayServerDraftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds gateway server saves without changing selected-machine-safe config values", () => {
|
it("builds gateway server saves without dropping preserved config values", () => {
|
||||||
expect(gatewayServerConfigFromDraft({
|
expect(gatewayServerConfigFromDraft({
|
||||||
host: " gateway.local ",
|
host: " gateway.local ",
|
||||||
port: "9000",
|
port: "9000",
|
||||||
allowedHostsMode: "all",
|
allowedHostsMode: "all",
|
||||||
allowedHostsText: "ignored.local",
|
allowedHostsText: "ignored.local",
|
||||||
}, {
|
}, {
|
||||||
|
shortcuts: { "core:view.chat": "mod+1" },
|
||||||
|
plugins: { info: { enabled: false } },
|
||||||
pathAccess: { allowedPaths: ["/old"] },
|
pathAccess: { allowedPaths: ["/old"] },
|
||||||
uploads: { defaultFolder: "old/uploads" },
|
uploads: { defaultFolder: "old/uploads" },
|
||||||
maxUploadBytes: 1234,
|
maxUploadBytes: 1234,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
|
subsessions: false,
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
host: "gateway.local",
|
host: "gateway.local",
|
||||||
port: 9000,
|
port: 9000,
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
|
shortcuts: { "core:view.chat": "mod+1" },
|
||||||
|
plugins: { info: { enabled: false } },
|
||||||
pathAccess: { allowedPaths: ["/old"] },
|
pathAccess: { allowedPaths: ["/old"] },
|
||||||
uploads: { defaultFolder: "old/uploads" },
|
uploads: { defaultFolder: "old/uploads" },
|
||||||
maxUploadBytes: 1234,
|
maxUploadBytes: 1234,
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
|
subsessions: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(gatewayServerConfigFromDraft({
|
||||||
|
host: "",
|
||||||
|
port: "",
|
||||||
|
allowedHostsMode: "list",
|
||||||
|
allowedHostsText: "example.local, 192.168.1.20\n",
|
||||||
|
})).toEqual({ allowedHosts: ["example.local", "192.168.1.20"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => {
|
it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => {
|
||||||
@@ -77,64 +89,10 @@ describe("settings config drafts", () => {
|
|||||||
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal.");
|
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts PI WEB config values to editable general settings drafts", () => {
|
it("rejects relative external paths before saving selected-machine access", () => {
|
||||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
|
expect(() => machineAccessConfigPatchFromDraft({
|
||||||
host: "0.0.0.0",
|
|
||||||
port: "8504",
|
|
||||||
allowedHostsMode: "list",
|
|
||||||
allowedHostsText: "example.local\n192.168.1.20",
|
|
||||||
allowedPathsText: "/tmp\n~/SDKs",
|
|
||||||
});
|
|
||||||
expect(draftFromConfig({ allowedHosts: true }).allowedHostsMode).toBe("all");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts drafts back to config while preserving non-general preferences", () => {
|
|
||||||
expect(configFromDraft({
|
|
||||||
host: " 127.0.0.1 ",
|
|
||||||
port: "9000",
|
|
||||||
allowedHostsMode: "list",
|
|
||||||
allowedHostsText: "example.local, 192.168.1.20\n",
|
|
||||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
|
||||||
}, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({
|
|
||||||
host: "127.0.0.1",
|
|
||||||
port: 9000,
|
|
||||||
allowedHosts: ["example.local", "192.168.1.20"],
|
|
||||||
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
|
||||||
plugins: { info: { enabled: false } },
|
|
||||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
|
||||||
uploads: { defaultFolder: "manual/uploads" },
|
|
||||||
maxUploadBytes: 1234,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes global path access when the allowed paths field is cleared", () => {
|
|
||||||
expect(configFromDraft({
|
|
||||||
host: "",
|
|
||||||
port: "",
|
|
||||||
allowedHostsMode: "list",
|
|
||||||
allowedHostsText: "",
|
|
||||||
allowedPathsText: "",
|
|
||||||
}, { pathAccess: { allowedPaths: ["/old"] } })).not.toHaveProperty("pathAccess");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects relative external paths before saving", () => {
|
|
||||||
expect(() => configFromDraft({
|
|
||||||
host: "",
|
|
||||||
port: "",
|
|
||||||
allowedHostsMode: "list",
|
|
||||||
allowedHostsText: "",
|
|
||||||
allowedPathsText: "relative/path",
|
allowedPathsText: "relative/path",
|
||||||
|
uploadDefaultFolder: "",
|
||||||
})).toThrow("Allowed external paths must be absolute paths or start with ~");
|
})).toThrow("Allowed external paths must be absolute paths or start with ~");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the spawnSessions flag when saving general settings", () => {
|
|
||||||
const result = configFromDraft({
|
|
||||||
host: "",
|
|
||||||
port: "",
|
|
||||||
allowedHostsMode: "list",
|
|
||||||
allowedHostsText: "",
|
|
||||||
allowedPathsText: "",
|
|
||||||
}, { spawnSessions: true });
|
|
||||||
expect(result.spawnSessions).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ export interface MachineAccessConfigDraft {
|
|||||||
uploadDefaultFolder: string;
|
uploadDefaultFolder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigDraft extends GatewayServerConfigDraft {
|
|
||||||
allowedPathsText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
|
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
|
||||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
||||||
}
|
}
|
||||||
@@ -40,10 +36,6 @@ export function machineAccessDraftFromConfig(config: PiWebConfigValues): Machine
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
|
||||||
return { ...gatewayServerDraftFromConfig(config), allowedPathsText: machineAccessDraftFromConfig(config).allowedPathsText };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||||
const config = preservedGatewayConfigRemainder(baseConfig);
|
const config = preservedGatewayConfigRemainder(baseConfig);
|
||||||
const host = draft.host.trim();
|
const host = draft.host.trim();
|
||||||
@@ -67,14 +59,6 @@ export function machineAccessConfigPatchFromDraft(draft: MachineAccessConfigDraf
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
|
||||||
const config = gatewayServerConfigFromDraft(draft, baseConfig);
|
|
||||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
|
||||||
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
|
|
||||||
else delete config.pathAccess;
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues {
|
function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues {
|
||||||
return {
|
return {
|
||||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessio
|
|||||||
describe("session daemon settings config helpers", () => {
|
describe("session daemon settings config helpers", () => {
|
||||||
it("builds daemon-only save patches for the sessiond toggles", () => {
|
it("builds daemon-only save patches for the sessiond toggles", () => {
|
||||||
expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false });
|
expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false });
|
||||||
expect(Object.keys(spawnSessionsConfigPatch(false))).toEqual(["spawnSessions"]);
|
|
||||||
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
||||||
expect(Object.keys(subsessionsConfigPatch(true))).toEqual(["subsessions"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
|
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api as defaultApi, type MessagePage, type PromptAttachment, type Sessio
|
|||||||
import type { SessionUiEvent } from "../sessionSocket";
|
import type { SessionUiEvent } from "../sessionSocket";
|
||||||
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
|
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||||
import { machineSessionKey } from "../machineKeys";
|
import { machineSessionKey } from "../machineKeys";
|
||||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||||
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
import { loadDraft, saveDraft } from "../promptDraftStorage";
|
||||||
@@ -1414,8 +1415,10 @@ describe("SessionController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
|
it("reloads the selected session from disk, discards the cached transcript, and re-fetches history", async () => {
|
||||||
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
|
|
||||||
const persistedSession = { ...oldSession, persisted: true };
|
const persistedSession = { ...oldSession, persisted: true };
|
||||||
|
const cacheKey = sessionKey(oldSession.id);
|
||||||
|
const freshPage: MessagePage = { messages: [{ role: "assistant", content: "fresh from disk" }], start: 1, total: 2 };
|
||||||
|
const cachedPages = new Map<string, MessagePage>([[cacheKey, { messages: [{ role: "user", content: "stale cached transcript" }], start: 0, total: 2 }]]);
|
||||||
const reloadCalls: string[] = [];
|
const reloadCalls: string[] = [];
|
||||||
const messageCalls: string[] = [];
|
const messageCalls: string[] = [];
|
||||||
let state: AppState = {
|
let state: AppState = {
|
||||||
@@ -1433,7 +1436,7 @@ describe("SessionController", () => {
|
|||||||
},
|
},
|
||||||
messages: (session) => {
|
messages: (session) => {
|
||||||
messageCalls.push(sessionLookupId(session));
|
messageCalls.push(sessionLookupId(session));
|
||||||
return Promise.resolve(emptyPage);
|
return Promise.resolve(freshPage);
|
||||||
},
|
},
|
||||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||||
};
|
};
|
||||||
@@ -1442,13 +1445,24 @@ describe("SessionController", () => {
|
|||||||
(patch) => { state = { ...state, ...patch }; },
|
(patch) => { state = { ...state, ...patch }; },
|
||||||
() => undefined,
|
() => undefined,
|
||||||
new InMemorySessionSelectionMemory(),
|
new InMemorySessionSelectionMemory(),
|
||||||
{ api, socket: new FakeSocket() },
|
{
|
||||||
|
api,
|
||||||
|
socket: new FakeSocket(),
|
||||||
|
transcripts: new ChatTranscriptStore({
|
||||||
|
read: (sessionId) => cachedPages.get(sessionId),
|
||||||
|
write: (sessionId, page) => { cachedPages.set(sessionId, page); },
|
||||||
|
remove: (sessionId) => { cachedPages.delete(sessionId); },
|
||||||
|
}),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await controller.reloadSession(persistedSession);
|
await controller.reloadSession(persistedSession);
|
||||||
|
|
||||||
expect(reloadCalls).toEqual([oldSession.id]);
|
expect(reloadCalls).toEqual([oldSession.id]);
|
||||||
expect(messageCalls).toContain(oldSession.id);
|
expect(messageCalls).toEqual([oldSession.id]);
|
||||||
|
expect(cachedPages.get(cacheKey)).toEqual(freshPage);
|
||||||
|
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh from disk" }] }]);
|
||||||
|
expect(state.messagePageStart).toBe(1);
|
||||||
expect(state.error).toBe("");
|
expect(state.error).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,18 +28,12 @@ describe("selectPreferredSession", () => {
|
|||||||
expect(selectPreferredSession(sessions)).toBeUndefined();
|
expect(selectPreferredSession(sessions)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can remember an archived selected session", () => {
|
it("returns a remembered archived session before falling back to active sessions", () => {
|
||||||
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
||||||
|
|
||||||
expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1");
|
expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can remember an archived selected session when only archived sessions remain", () => {
|
|
||||||
const sessions = [{ ...testSession("s1"), archived: true }];
|
|
||||||
|
|
||||||
expect(selectPreferredSession(sessions, { latestSessionId: "s1" })?.id).toBe("s1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the first active session when the remembered session no longer exists", () => {
|
it("falls back to the first active session when the remembered session no longer exists", () => {
|
||||||
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
const sessions = [{ ...testSession("s1"), archived: true }, testSession("s2")];
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe("selectPreferredWorkspace", () => {
|
|||||||
expect(selectPreferredWorkspace(workspaces, { latestWorkspaceId: "old" })?.id).toBe("main");
|
expect(selectPreferredWorkspace(workspaces, { latestWorkspaceId: "old" })?.id).toBe("main");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves explicit invalid target behavior", () => {
|
it("does not fall back to remembered workspace when the explicit target is invalid", () => {
|
||||||
const workspaces = [testWorkspace("main"), testWorkspace("feature")];
|
const workspaces = [testWorkspace("main"), testWorkspace("feature")];
|
||||||
|
|
||||||
expect(selectPreferredWorkspace(workspaces, { targetWorkspaceId: "old", latestWorkspaceId: "feature" })).toBeUndefined();
|
expect(selectPreferredWorkspace(workspaces, { targetWorkspaceId: "old", latestWorkspaceId: "feature" })).toBeUndefined();
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes";
|
import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes";
|
||||||
|
|
||||||
describe("inputModeForDraft", () => {
|
describe("input mode helpers", () => {
|
||||||
it("detects shell input and context-excluded shell input after leading whitespace", () => {
|
it("detects shell mode and context-excluded shell mode after leading whitespace", () => {
|
||||||
expect(inputModeForDraft(" ! npm test")).toEqual({ kind: "shell", excludeFromContext: false });
|
expect(inputModeForDraft(" ! npm test")).toEqual({ kind: "shell", excludeFromContext: false });
|
||||||
expect(inputModeForDraft("\n!! secret command")).toEqual({ kind: "shell", excludeFromContext: true });
|
expect(inputModeForDraft("\n!! secret command")).toEqual({ kind: "shell", excludeFromContext: true });
|
||||||
expect(isShellInput(" ! pwd")).toBe(true);
|
expect(isShellInput(" ! pwd")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects slash commands only for the current token", () => {
|
it("detects slash-command mode from the current token", () => {
|
||||||
expect(inputModeForDraft("/compact")).toEqual({ kind: "command" });
|
expect(inputModeForDraft("/compact")).toEqual({ kind: "command" });
|
||||||
expect(inputModeForDraft("please /compact")).toEqual({ kind: "command" });
|
expect(inputModeForDraft("please /compact")).toEqual({ kind: "command" });
|
||||||
expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" });
|
expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats modes as equal only when kind and shell context-exclusion match", () => {
|
it("compares modes by kind and shell context-exclusion", () => {
|
||||||
expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true);
|
expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true);
|
||||||
expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false);
|
expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false);
|
||||||
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true);
|
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true);
|
||||||
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false);
|
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects file completion contexts", () => {
|
it("collapses file completion triggers to file mode", () => {
|
||||||
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
|
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
|
||||||
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
|
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
|
||||||
expect(inputModeForDraft("open @ A FILE")).toEqual({ kind: "file" });
|
expect(inputModeForDraft("open @ A FILE")).toEqual({ kind: "file" });
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe("PluginRegistry", () => {
|
|||||||
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
|
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provides html and svg helpers to plugin activation", () => {
|
it("provides html and svg helpers to plugin activation and callbacks", () => {
|
||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
registry.register({
|
registry.register({
|
||||||
id: "example",
|
id: "example",
|
||||||
@@ -86,7 +86,10 @@ describe("PluginRegistry", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined();
|
const panel = registry.getWorkspacePanels()[0];
|
||||||
|
|
||||||
|
expect(panel?.icon).toBeDefined();
|
||||||
|
expect(panel?.render(createWorkspacePanelContext("local"))).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes the prompt helper to workspace panel callbacks", () => {
|
it("exposes the prompt helper to workspace panel callbacks", () => {
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ afterEach(() => {
|
|||||||
Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true });
|
Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
function installWindow(href: string): { pushed: string[] } {
|
function installWindow(href: string): { pushed: string[]; replaced: string[] } {
|
||||||
const url = new URL(href);
|
const url = new URL(href);
|
||||||
const pushed: string[] = [];
|
const pushed: string[] = [];
|
||||||
|
const replaced: string[] = [];
|
||||||
const fakeWindow = {
|
const fakeWindow = {
|
||||||
location: {
|
location: {
|
||||||
href: url.href,
|
href: url.href,
|
||||||
@@ -23,12 +24,12 @@ function installWindow(href: string): { pushed: string[] } {
|
|||||||
pushed.push(String(next));
|
pushed.push(String(next));
|
||||||
}),
|
}),
|
||||||
replaceState: vi.fn((_state: object, _title: string, next: URL | string) => {
|
replaceState: vi.fn((_state: object, _title: string, next: URL | string) => {
|
||||||
pushed.push(String(next));
|
replaced.push(String(next));
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||||
return { pushed };
|
return { pushed, replaced };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("route helpers", () => {
|
describe("route helpers", () => {
|
||||||
@@ -51,8 +52,8 @@ describe("route helpers", () => {
|
|||||||
expect(readRoute()).toMatchObject({ tool: undefined, view: undefined });
|
expect(readRoute()).toMatchObject({ tool: undefined, view: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes compact URLs and preserves path/hash", () => {
|
it("writes compact URLs with push history and preserves path/hash", () => {
|
||||||
const { pushed } = installWindow("http://localhost/app?old=1#section");
|
const { pushed, replaced } = installWindow("http://localhost/app?old=1#section");
|
||||||
const route: AppRoute = {
|
const route: AppRoute = {
|
||||||
machineId: "remote",
|
machineId: "remote",
|
||||||
projectId: "project/id",
|
projectId: "project/id",
|
||||||
@@ -65,13 +66,15 @@ describe("route helpers", () => {
|
|||||||
writeRoute(route);
|
writeRoute(route);
|
||||||
|
|
||||||
expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
|
expect(pushed).toEqual(["http://localhost/app?old=1&machine=remote&project=project%2Fid&workspace=workspace+id&tool=core%3Aworkspace.files&view=chat#section"]);
|
||||||
|
expect(replaced).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not push history when the route is unchanged", () => {
|
it("does not write history when the route is unchanged", () => {
|
||||||
const { pushed } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
|
const { pushed, replaced } = installWindow("http://localhost/app?project=p1&tool=core%3Aworkspace.git");
|
||||||
|
|
||||||
writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
|
writeRoute({ machineId: undefined, projectId: "p1", workspaceId: undefined, sessionId: undefined, tool: "core:workspace.git", view: undefined });
|
||||||
|
|
||||||
expect(pushed).toEqual([]);
|
expect(pushed).toEqual([]);
|
||||||
|
expect(replaced).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,17 +93,21 @@ describe("resolveThemePreference", () => {
|
|||||||
expect(resolution.activeTheme?.id).toBe("themes:classic");
|
expect(resolution.activeTheme?.id).toBe("themes:classic");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite a missing selected theme preference in the resolution result", () => {
|
it("falls back to Classic without mutating a missing selected theme preference", () => {
|
||||||
const missingThemeId: QualifiedContributionId = "plugin:missing";
|
const preference = {
|
||||||
|
themeId: "plugin:missing",
|
||||||
|
auto: true,
|
||||||
|
} satisfies { themeId: QualifiedContributionId; auto: boolean };
|
||||||
const resolution = resolveThemePreference({
|
const resolution = resolveThemePreference({
|
||||||
themes,
|
themes,
|
||||||
themePairs,
|
themePairs,
|
||||||
preference: { themeId: missingThemeId, auto: true },
|
preference,
|
||||||
prefersLight: false,
|
prefersLight: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolution.selectedTheme?.id).toBe("themes:classic");
|
expect(resolution.selectedTheme?.id).toBe("themes:classic");
|
||||||
expect(missingThemeId).toBe("plugin:missing");
|
expect(resolution.activeTheme?.id).toBe("themes:classic");
|
||||||
|
expect(preference).toEqual({ themeId: "plugin:missing", auto: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can look up a pair from either member theme", () => {
|
it("can look up a pair from either member theme", () => {
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ function activity(cwd: string, patch: Partial<WorkspaceActivity> = {}): Workspac
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("workspace activity aggregation", () => {
|
describe("workspace activity aggregation", () => {
|
||||||
it("matches activity to workspace paths", () => {
|
it("matches activity to workspace paths rather than ids", () => {
|
||||||
const ws = workspace("p1", "/repo");
|
const ws = { ...workspace("p1", "/repo"), id: "workspace-1" };
|
||||||
expect(workspaceActivityFor(ws, { "/repo": activity("/repo") })?.hasSessionActivity).toBe(true);
|
const matched = activity("/repo");
|
||||||
|
|
||||||
|
expect(workspaceActivityFor(ws, { "/repo": matched, "workspace-1": activity("workspace-1") })).toEqual(matched);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a terminal indicator only when there is no session activity", () => {
|
it("uses a terminal indicator only when there is no session activity", () => {
|
||||||
|
|||||||
@@ -47,10 +47,18 @@ describe("workspace deletion state", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports pending workspace deletions for disabling repeated actions", () => {
|
it("reports only queued or running workspace deletions as pending", () => {
|
||||||
const state = { workspaceDeletionRuns: { w1: run("new", "w1", "2026-05-25T00:00:01.000Z", "running") } };
|
const state = {
|
||||||
|
workspaceDeletionRuns: {
|
||||||
|
w1: run("running", "w1", "2026-05-25T00:00:01.000Z", "running"),
|
||||||
|
w2: run("queued", "w2", "2026-05-25T00:00:02.000Z", "queued"),
|
||||||
|
w3: run("succeeded", "w3", "2026-05-25T00:00:03.000Z", "succeeded"),
|
||||||
|
w4: run("failed", "w4", "2026-05-25T00:00:04.000Z", "failed"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
expect(isWorkspaceDeletionPending(state, workspace)).toBe(true);
|
expect(isWorkspaceDeletionPending(state, workspace)).toBe(true);
|
||||||
expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1"]);
|
expect(isWorkspaceDeletionPending(state, { ...workspace, id: "w3" })).toBe(false);
|
||||||
|
expect(pendingWorkspaceDeletionIds(state.workspaceDeletionRuns)).toEqual(["w1", "w2"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-2
@@ -18,9 +18,23 @@ afterEach(async () => {
|
|||||||
|
|
||||||
describe("PI WEB config persistence", () => {
|
describe("PI WEB config persistence", () => {
|
||||||
it("writes and reads the configured PI WEB config path", () => {
|
it("writes and reads the configured PI WEB config path", () => {
|
||||||
const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }, testOptions());
|
const requestedConfig = {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 9000,
|
||||||
|
allowedHosts: ["example.local"],
|
||||||
|
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
||||||
|
plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } },
|
||||||
|
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||||
|
uploads: { defaultFolder: "manual\\incoming" },
|
||||||
|
};
|
||||||
|
const normalizedConfig = {
|
||||||
|
...requestedConfig,
|
||||||
|
uploads: { defaultFolder: "manual/incoming" },
|
||||||
|
};
|
||||||
|
|
||||||
expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } } });
|
const saved = savePiWebConfig(requestedConfig, testOptions());
|
||||||
|
|
||||||
|
expect(saved).toEqual({ path: configPath, exists: true, config: normalizedConfig });
|
||||||
expect(loadPiWebConfig(testOptions())).toEqual(saved);
|
expect(loadPiWebConfig(testOptions())).toEqual(saved);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,15 +35,32 @@ describe("config routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updates config through the service", async () => {
|
it("updates config through the service", async () => {
|
||||||
|
const requestedConfig: PiWebConfigValues = {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 9000,
|
||||||
|
allowedHosts: true,
|
||||||
|
spawnSessions: true,
|
||||||
|
subsessions: true,
|
||||||
|
shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null },
|
||||||
|
plugins: { info: { enabled: false, settings: { note: "hidden" } } },
|
||||||
|
pathAccess: { allowedPaths: ["/tmp"] },
|
||||||
|
uploads: { defaultFolder: "uploads\\manual" },
|
||||||
|
maxUploadBytes: 1234,
|
||||||
|
};
|
||||||
|
const expectedConfig: PiWebConfigValues = {
|
||||||
|
...requestedConfig,
|
||||||
|
uploads: { defaultFolder: "uploads/manual" },
|
||||||
|
};
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: "/api/config",
|
url: "/api/config",
|
||||||
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } },
|
payload: { config: requestedConfig },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 });
|
expect(savedConfig).toEqual(expectedConfig);
|
||||||
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
|
expect(response.json<PiWebConfigResponse>().config).toEqual(expectedConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid config payloads before writing", async () => {
|
it("rejects invalid config payloads before writing", async () => {
|
||||||
@@ -109,11 +126,16 @@ describe("config routes", () => {
|
|||||||
|
|
||||||
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
|
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
|
||||||
savedConfig = fullConfig();
|
savedConfig = fullConfig();
|
||||||
|
const selectedMachinePatch: PiWebConfigValues = {
|
||||||
|
plugins: { info: { enabled: false } },
|
||||||
|
uploads: { defaultFolder: "uploads\\manual" },
|
||||||
|
spawnSessions: true,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: "/api/machines/local/config",
|
url: "/api/machines/local/config",
|
||||||
payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } },
|
payload: { config: selectedMachinePatch },
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedConfig: PiWebConfigValues = {
|
const expectedConfig: PiWebConfigValues = {
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ async function withUnixSocket<T>(socketPath: string, callback: () => Promise<T>)
|
|||||||
function cleanProcessEnv(): NodeJS.ProcessEnv {
|
function cleanProcessEnv(): NodeJS.ProcessEnv {
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
for (const key of Object.keys(env)) {
|
for (const key of Object.keys(env)) {
|
||||||
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key.startsWith("PI_WEB_")) {
|
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key === "XDG_DATA_HOME" || key.startsWith("PI_WEB_")) {
|
||||||
Reflect.deleteProperty(env, key);
|
Reflect.deleteProperty(env, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe("MachineService", () => {
|
|||||||
expect(await service.list()).toEqual([
|
expect(await service.list()).toEqual([
|
||||||
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
|
{ id: "local", name: "Local", kind: "local", createdAt: "1970-01-01T00:00:00.000Z", updatedAt: "1970-01-01T00:00:00.000Z" },
|
||||||
]);
|
]);
|
||||||
|
await expect(stat(storePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds remote machines and omits secrets from public responses", async () => {
|
it("adds remote machines and omits secrets from public responses", async () => {
|
||||||
@@ -38,8 +39,7 @@ describe("MachineService", () => {
|
|||||||
await expectOwnerOnlyMachineStore(storePath);
|
await expectOwnerOnlyMachineStore(storePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tightens permissions after reading an existing machine store", async () => {
|
it.skipIf(process.platform === "win32")("tightens permissions after reading an existing machine store", async () => {
|
||||||
if (process.platform === "win32") return;
|
|
||||||
await writeFile(storePath, `${JSON.stringify({
|
await writeFile(storePath, `${JSON.stringify({
|
||||||
machines: [{
|
machines: [{
|
||||||
id: "remote-1",
|
id: "remote-1",
|
||||||
|
|||||||
@@ -85,9 +85,11 @@ describe("registerPiPackageRoutes", () => {
|
|||||||
expect(missingSource.statusCode).toBe(400);
|
expect(missingSource.statusCode).toBe(400);
|
||||||
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
expect(missingSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||||
expect(blankSource.statusCode).toBe(400);
|
expect(blankSource.statusCode).toBe(400);
|
||||||
|
expect(blankSource.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||||
expect(invalidScope.statusCode).toBe(400);
|
expect(invalidScope.statusCode).toBe(400);
|
||||||
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
|
expect(invalidScope.json()).toEqual({ error: "Pi package scope must be \"user\" or \"project\"" });
|
||||||
expect(invalidUpdate.statusCode).toBe(400);
|
expect(invalidUpdate.statusCode).toBe(400);
|
||||||
|
expect(invalidUpdate.json()).toEqual({ error: "Pi package source must be a non-empty string" });
|
||||||
expect(serviceMocks.install).not.toHaveBeenCalled();
|
expect(serviceMocks.install).not.toHaveBeenCalled();
|
||||||
expect(serviceMocks.remove).not.toHaveBeenCalled();
|
expect(serviceMocks.remove).not.toHaveBeenCalled();
|
||||||
expect(serviceMocks.update).not.toHaveBeenCalled();
|
expect(serviceMocks.update).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -172,19 +172,30 @@ describe("PiWebPluginService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips duplicate plugin ids", async () => {
|
it("skips duplicate plugin ids", async () => {
|
||||||
await writePlugin(join(tempDir, "plugins", "one"), {
|
const firstRoot = join(tempDir, "first-root");
|
||||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
|
const secondRoot = join(tempDir, "second-root");
|
||||||
files: { "pi-web-plugin.js": "export default {};" },
|
await writePlugin(join(firstRoot, "duplicate"), {
|
||||||
|
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "first.js" }] } },
|
||||||
|
files: { "first.js": "export default {};" },
|
||||||
});
|
});
|
||||||
await writePlugin(join(tempDir, "plugins", "two"), {
|
await writePlugin(join(secondRoot, "duplicate"), {
|
||||||
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
|
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "second.js", machineSpecific: true }] } },
|
||||||
files: { "pi-web-plugin.js": "export default {};" },
|
files: { "second.js": "export default {};" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
const service = new PiWebPluginService({
|
||||||
|
roots: [
|
||||||
|
{ path: firstRoot, source: "first", scope: "local" },
|
||||||
|
{ path: secondRoot, source: "second", scope: "local" },
|
||||||
|
],
|
||||||
|
packageProvider: false,
|
||||||
|
});
|
||||||
|
|
||||||
const manifest = await service.manifest();
|
const manifest = await service.manifest();
|
||||||
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]);
|
expect(manifest.plugins).toEqual([
|
||||||
|
expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }),
|
||||||
|
]);
|
||||||
|
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
|
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
|
||||||
|
|||||||
@@ -90,8 +90,7 @@ describe("PI WEB status", () => {
|
|||||||
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
|
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suggests native systemd commands for local development services", async () => {
|
it.skipIf(process.platform !== "linux")("suggests native systemd commands for local development services", async () => {
|
||||||
if (process.platform !== "linux") return;
|
|
||||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||||
disableDockerRuntimeEnv();
|
disableDockerRuntimeEnv();
|
||||||
const home = await tempHome();
|
const home = await tempHome();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe("auth provider options", () => {
|
|||||||
expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
|
expect(isApiKeyLoginProvider("openai", new Set(["openai-codex"]))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes Anthropic in both OAuth and API key login options", () => {
|
it("builds login options for OAuth-only, dual-auth, and API-key providers", () => {
|
||||||
const options = getLoginProviderOptions(registry());
|
const options = getLoginProviderOptions(registry());
|
||||||
expect(options).toEqual(expect.arrayContaining([
|
expect(options).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
|
expect.objectContaining({ id: "anthropic", authType: "oauth" }),
|
||||||
@@ -44,7 +44,7 @@ describe("auth provider options", () => {
|
|||||||
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
|
expect(options).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "openai-codex", authType: "api_key" })]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns only stored credentials for logout", () => {
|
it("returns only currently stored credentials for logout", () => {
|
||||||
expect(getLogoutProviderOptions(registry())).toEqual([
|
expect(getLogoutProviderOptions(registry())).toEqual([
|
||||||
expect.objectContaining({ id: "openai", authType: "api_key" }),
|
expect.objectContaining({ id: "openai", authType: "api_key" }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -169,23 +169,6 @@ function emptyArchiveStore(): NonNullable<PiSessionServiceDependencies["archiveS
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("PiSessionService", () => {
|
describe("PiSessionService", () => {
|
||||||
it("exposes the session's agent.streamFn for one-off model calls", async () => {
|
|
||||||
const hub = new CapturingSessionEventHub();
|
|
||||||
const streamFn = vi.fn();
|
|
||||||
const fake = fakeRuntime("stream-session", { agent: { streamFn } });
|
|
||||||
const service = new PiSessionService(hub, {
|
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
|
||||||
sessionManager: sessionGateway([]),
|
|
||||||
heartbeatIntervalMs: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.start("/workspace");
|
|
||||||
|
|
||||||
expect(fake.session.agent.streamFn).toBe(streamFn);
|
|
||||||
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts sessions through an injected runtime creator", async () => {
|
it("starts sessions through an injected runtime creator", async () => {
|
||||||
const hub = new CapturingSessionEventHub();
|
const hub = new CapturingSessionEventHub();
|
||||||
const fake = fakeRuntime();
|
const fake = fakeRuntime();
|
||||||
@@ -957,14 +940,21 @@ describe("PiSessionService", () => {
|
|||||||
|
|
||||||
it("rejects malformed prompt text before opening the runtime", async () => {
|
it("rejects malformed prompt text before opening the runtime", async () => {
|
||||||
const fake = fakeRuntime("prompt-session");
|
const fake = fakeRuntime("prompt-session");
|
||||||
|
let createCalls = 0;
|
||||||
|
const createAgentRuntime: RuntimeCreator = async () => {
|
||||||
|
createCalls += 1;
|
||||||
|
await Promise.resolve();
|
||||||
|
return fake.runtime;
|
||||||
|
};
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
createAgentRuntime,
|
||||||
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
sessionManager: sessionGateway([sessionRecord("prompt-session")]),
|
||||||
heartbeatIntervalMs: 60_000,
|
heartbeatIntervalMs: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
|
await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required");
|
||||||
|
|
||||||
|
expect(createCalls).toBe(0);
|
||||||
expect(fake.calls.prompt).toEqual([]);
|
expect(fake.calls.prompt).toEqual([]);
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
@@ -1313,7 +1303,7 @@ describe("PiSessionService", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
|
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
|
||||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
const { child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" });
|
||||||
await service.start("/workspace"); // bring the parent online so it can be notified
|
await service.start("/workspace"); // bring the parent online so it can be notified
|
||||||
|
|
||||||
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
|
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" });
|
||||||
@@ -1323,7 +1313,6 @@ describe("PiSessionService", () => {
|
|||||||
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
|
await expect(service.listSubsessions("parent-1")).resolves.toEqual([
|
||||||
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
|
{ sessionId: "child-1", cwd: "/workspace-feature", status: "idle" },
|
||||||
]);
|
]);
|
||||||
void parent;
|
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,15 @@ describe("SessionCommandService", () => {
|
|||||||
await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" });
|
await expect(service.run("s1", "/template arg")).resolves.toMatchObject({ type: "done" });
|
||||||
await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" });
|
await expect(service.run("s1", "/skill:skill-a arg")).resolves.toMatchObject({ type: "done" });
|
||||||
expect(prompt).toHaveBeenCalledTimes(3);
|
expect(prompt).toHaveBeenCalledTimes(3);
|
||||||
|
expect(prompt).toHaveBeenNthCalledWith(1, "s1", "/ext arg");
|
||||||
|
expect(prompt).toHaveBeenNthCalledWith(2, "s1", "/template arg");
|
||||||
|
expect(prompt).toHaveBeenNthCalledWith(3, "s1", "/skill:skill-a arg");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames sessions and returns updated client session metadata", async () => {
|
it("renames sessions, publishes the name update, and returns updated client session metadata", async () => {
|
||||||
const active = activeSession();
|
const active = activeSession();
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher());
|
const events = eventPublisher();
|
||||||
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), events);
|
||||||
|
|
||||||
await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({
|
await expect(service.run("s1", "/name Useful name")).resolves.toMatchObject({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -78,6 +82,7 @@ describe("SessionCommandService", () => {
|
|||||||
session: { id: "s1", cwd: "/work", name: "Useful name", messageCount: 2 },
|
session: { id: "s1", cwd: "/work", name: "Useful name", messageCount: 2 },
|
||||||
});
|
});
|
||||||
expect(active.runtime.session.setSessionName).toHaveBeenCalledWith("Useful name");
|
expect(active.runtime.session.setSessionName).toHaveBeenCalledWith("Useful name");
|
||||||
|
expect(events.publish).toHaveBeenCalledWith("s1", { type: "session.name", sessionId: "s1", name: "Useful name" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats session stats", async () => {
|
it("formats session stats", async () => {
|
||||||
@@ -90,18 +95,22 @@ describe("SessionCommandService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts compaction and publishes completion", async () => {
|
it("starts compaction, updates lifecycle hooks, and publishes completion", async () => {
|
||||||
const active = activeSession();
|
const active = activeSession();
|
||||||
const events = eventPublisher();
|
const events = eventPublisher();
|
||||||
const service = new SessionCommandService(() => getActive(active), vi.fn(), events);
|
const onCompactionStart = vi.fn();
|
||||||
|
const onCompactionEnd = vi.fn();
|
||||||
|
const service = new SessionCommandService(() => getActive(active), vi.fn(), events, { onCompactionStart, onCompactionEnd });
|
||||||
|
|
||||||
await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" });
|
await expect(service.run("s1", "/compact focus on tests")).resolves.toEqual({ type: "done", message: "Compaction started…" });
|
||||||
|
expect(onCompactionStart).toHaveBeenCalledWith(active.runtime.session);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(events.publish).toHaveBeenCalledWith("s1", {
|
expect(events.publish).toHaveBeenCalledWith("s1", {
|
||||||
type: "command.output",
|
type: "command.output",
|
||||||
level: "success",
|
level: "success",
|
||||||
message: "Compaction complete.\nTokens before: 123\n\nshort summary",
|
message: "Compaction complete.\nTokens before: 123\n\nshort summary",
|
||||||
});
|
});
|
||||||
|
expect(onCompactionEnd).toHaveBeenCalledWith(active.runtime.session, "success");
|
||||||
});
|
});
|
||||||
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
|
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
|
|||||||
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
|
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
|
||||||
|
|
||||||
describe("createSpawnSessionToolDefinition", () => {
|
describe("createSpawnSessionToolDefinition", () => {
|
||||||
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
|
it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => {
|
||||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
|
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
|
||||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ describe("createSpawnSessionToolDefinition", () => {
|
|||||||
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
|
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => {
|
it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => {
|
||||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
|
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
|
||||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||||
|
|
||||||
@@ -29,15 +29,6 @@ describe("createSpawnSessionToolDefinition", () => {
|
|||||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the inherited model when the dispatching session has no current model", async () => {
|
|
||||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a" }));
|
|
||||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
|
||||||
|
|
||||||
await tool.execute("call-3", { prompt: "continue" }, undefined, undefined, ctx);
|
|
||||||
|
|
||||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates the spawn callback error so the agent loop reports it", async () => {
|
it("propagates the spawn callback error so the agent loop reports it", async () => {
|
||||||
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
|
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
|
||||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||||
|
|||||||
@@ -81,12 +81,12 @@ describe("buildTranscriptView", () => {
|
|||||||
expect(callPart.args).toEqual({ command: "ls" });
|
expect(callPart.args).toEqual({ command: "ls" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("search keeps only matching entries across text and tool names", () => {
|
it("search keeps only entries matching text or tool-call names", () => {
|
||||||
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read")];
|
const messages = [assistant("the auth flow"), assistant("unrelated"), toolResult("error in auth.ts", "read"), toolCall("auth-search")];
|
||||||
const view = buildTranscriptView(messages, { search: "auth" });
|
const view = buildTranscriptView(messages, { search: "auth" });
|
||||||
|
|
||||||
expect(view.matched).toBe(2);
|
expect(view.matched).toBe(3);
|
||||||
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2]);
|
expect(view.entries.map((entry) => entry.index)).toEqual([0, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("search runs against full content even when maxChars would clip the match away", () => {
|
it("search runs against full content even when maxChars would clip the match away", () => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe("terminal routes", () => {
|
|||||||
expect(terminals.events).toEqual([`close-cwd:${requestCwd}`]);
|
expect(terminals.events).toEqual([`close-cwd:${requestCwd}`]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates and lists terminal command runs with filters", async () => {
|
it("routes command-run create, filter, cancel, and terminal continue requests", async () => {
|
||||||
const createResponse = await app.inject({
|
const createResponse = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/terminal-command-runs",
|
url: "/terminal-command-runs",
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ describe("normalizeRequestCwd", () => {
|
|||||||
expect(normalizeRequestCwd(join(absoluteBase, ".", "nested", ".."))).toBe(absoluteBase);
|
expect(normalizeRequestCwd(join(absoluteBase, ".", "nested", ".."))).toBe(absoluteBase);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats Windows backslash and forward-slash paths as equal", () => {
|
it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => {
|
||||||
if (process.platform !== "win32") return;
|
|
||||||
expect(normalizeRequestCwd("C:/Users/dev/project")).toBe("C:\\Users\\dev\\project");
|
expect(normalizeRequestCwd("C:/Users/dev/project")).toBe("C:\\Users\\dev\\project");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,8 +46,7 @@ describe("cwdPathsEqual", () => {
|
|||||||
expect(cwdPathsEqual(absoluteBase, join(absoluteBase, "."))).toBe(true);
|
expect(cwdPathsEqual(absoluteBase, join(absoluteBase, "."))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats Windows backslash and forward-slash paths as equal", () => {
|
it.skipIf(process.platform !== "win32")("treats Windows backslash and forward-slash paths as equal", () => {
|
||||||
if (process.platform !== "win32") return;
|
|
||||||
expect(cwdPathsEqual("C:\\Users\\dev\\project", "C:/Users/dev/project")).toBe(true);
|
expect(cwdPathsEqual("C:\\Users\\dev\\project", "C:/Users/dev/project")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -130,13 +130,14 @@ describe("writeWorkspaceFile", () => {
|
|||||||
expect(content).toBe("const greeting = 'hello';\n");
|
expect(content).toBe("const greeting = 'hello';\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes binary content", async () => {
|
it("writes binary content without text re-encoding", async () => {
|
||||||
const root = await tempWorkspace();
|
const root = await tempWorkspace();
|
||||||
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
||||||
|
|
||||||
const result = await writeWorkspaceFile(root, "image.png", binaryData);
|
const result = await writeWorkspaceFile(root, "image.png", binaryData);
|
||||||
|
|
||||||
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
|
expect(result).toMatchObject({ path: "image.png", created: true, size: 6 });
|
||||||
|
await expect(readFile(join(root, "image.png"))).resolves.toEqual(binaryData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overwrites existing files by default", async () => {
|
it("overwrites existing files by default", async () => {
|
||||||
@@ -190,14 +191,12 @@ describe("writeWorkspaceFile", () => {
|
|||||||
it("prevents writing through symlinks that escape the workspace", async () => {
|
it("prevents writing through symlinks that escape the workspace", async () => {
|
||||||
const root = await tempWorkspace();
|
const root = await tempWorkspace();
|
||||||
await mkdir(join(root, "subdir"), { recursive: true });
|
await mkdir(join(root, "subdir"), { recursive: true });
|
||||||
// Create a symlink inside the workspace that points outside
|
|
||||||
const { symlink } = await import("node:fs/promises");
|
|
||||||
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
|
const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-"));
|
||||||
roots.push(outsideDir);
|
roots.push(outsideDir);
|
||||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||||
|
|
||||||
// Attempting to write through the symlink should be blocked
|
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow("Path escapes workspace");
|
||||||
await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow();
|
await expect(readFile(join(outsideDir, "evil.txt"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,7 +226,7 @@ describe("deleteWorkspaceFile", () => {
|
|||||||
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
|
await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects path traversal", async () => {
|
it("rejects traversal and absolute paths", async () => {
|
||||||
const root = await tempWorkspace();
|
const root = await tempWorkspace();
|
||||||
|
|
||||||
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||||
@@ -253,7 +252,7 @@ describe("deleteWorkspaceFile", () => {
|
|||||||
|
|
||||||
expect(result).toMatchObject({ path: "link.txt", existed: true });
|
expect(result).toMatchObject({ path: "link.txt", existed: true });
|
||||||
// The symlink should be gone, but the target file should still exist
|
// The symlink should be gone, but the target file should still exist
|
||||||
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow();
|
await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow("Path does not exist");
|
||||||
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
|
const realContent = await readFile(join(outsideDir, "real.txt"), "utf8");
|
||||||
expect(realContent).toBe("real content");
|
expect(realContent).toBe("real content");
|
||||||
});
|
});
|
||||||
@@ -307,6 +306,8 @@ describe("moveWorkspaceFile", () => {
|
|||||||
await writeFile(join(root, "file.txt"), "data");
|
await writeFile(join(root, "file.txt"), "data");
|
||||||
|
|
||||||
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
|
await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow();
|
||||||
|
const source = await readWorkspaceFile(root, "file.txt");
|
||||||
|
expect(source.content).toBe("data");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overwrites target when overwrite is true", async () => {
|
it("overwrites target when overwrite is true", async () => {
|
||||||
@@ -327,9 +328,11 @@ describe("moveWorkspaceFile", () => {
|
|||||||
await writeFile(join(root, "target.txt"), "target");
|
await writeFile(join(root, "target.txt"), "target");
|
||||||
|
|
||||||
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
|
await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists");
|
||||||
// Source should still exist
|
// Source and target should remain unchanged
|
||||||
const source = await readWorkspaceFile(root, "source.txt");
|
const source = await readWorkspaceFile(root, "source.txt");
|
||||||
expect(source.content).toBe("source");
|
expect(source.content).toBe("source");
|
||||||
|
const target = await readWorkspaceFile(root, "target.txt");
|
||||||
|
expect(target.content).toBe("target");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects source path traversal", async () => {
|
it("rejects source path traversal", async () => {
|
||||||
@@ -342,7 +345,9 @@ describe("moveWorkspaceFile", () => {
|
|||||||
const root = await tempWorkspace();
|
const root = await tempWorkspace();
|
||||||
await writeFile(join(root, "source.txt"), "data");
|
await writeFile(join(root, "source.txt"), "data");
|
||||||
|
|
||||||
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow();
|
await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow("Path traversal is not allowed");
|
||||||
|
const source = await readWorkspaceFile(root, "source.txt");
|
||||||
|
expect(source.content).toBe("data");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects moving a directory", async () => {
|
it("rejects moving a directory", async () => {
|
||||||
@@ -370,6 +375,9 @@ describe("moveWorkspaceFile", () => {
|
|||||||
roots.push(outsideDir);
|
roots.push(outsideDir);
|
||||||
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
await symlink(outsideDir, join(root, "subdir", "escape"), "junction");
|
||||||
|
|
||||||
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow();
|
await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow("Path escapes workspace");
|
||||||
|
const source = await readWorkspaceFile(root, "subdir/file.txt");
|
||||||
|
expect(source.content).toBe("data");
|
||||||
|
await expect(readFile(join(outsideDir, "evil.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("workspace deletion routes", () => {
|
describe("workspace deletion routes", () => {
|
||||||
it("closes target workspace terminals before starting the deletion terminal command", async () => {
|
it("closes target workspace terminals before starting deletion from the main workspace", async () => {
|
||||||
const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" });
|
const response = await app.inject({ method: "DELETE", url: "/api/projects/p1/workspaces/feature" });
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PI_WEB_CAPABILITIES } from "./capabilities";
|
|||||||
import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebRuntimeResponse, parsePiWebVersionResponse } from "./piWebStatusParsing";
|
import { parsePiWebComponentStatus, parsePiWebInstallationInfo, parsePiWebRuntimeResponse, parsePiWebVersionResponse } from "./piWebStatusParsing";
|
||||||
|
|
||||||
describe("PI WEB status parsing", () => {
|
describe("PI WEB status parsing", () => {
|
||||||
it("parses known runtime capabilities and ignores unknown string capabilities", () => {
|
it("parses known top-level and component capabilities while ignoring unknown strings", () => {
|
||||||
expect(parsePiWebRuntimeResponse({
|
expect(parsePiWebRuntimeResponse({
|
||||||
packageName: "@jmfederico/pi-web",
|
packageName: "@jmfederico/pi-web",
|
||||||
generatedAt: "now",
|
generatedAt: "now",
|
||||||
@@ -21,7 +21,7 @@ describe("PI WEB status parsing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects malformed capability arrays", () => {
|
it("rejects runtime responses with malformed component capability arrays", () => {
|
||||||
expect(parsePiWebRuntimeResponse({
|
expect(parsePiWebRuntimeResponse({
|
||||||
packageName: "@jmfederico/pi-web",
|
packageName: "@jmfederico/pi-web",
|
||||||
generatedAt: "now",
|
generatedAt: "now",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { base64ByteLength, extensionForImageMimeType, isSupportedImageMimeType, MAX_INLINE_IMAGE_BASE64_BYTES, parsePromptAttachments } from "./promptAttachments.js";
|
import { base64ByteLength, extensionForImageMimeType, isSupportedImageMimeType, MAX_INLINE_IMAGE_BASE64_BYTES, parsePromptAttachments } from "./promptAttachments.js";
|
||||||
|
|
||||||
const tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCA',".replace(/[^A-Za-z0-9+/=]/g, "");
|
const validImageBase64 = "QUJD";
|
||||||
|
|
||||||
describe("isSupportedImageMimeType", () => {
|
describe("isSupportedImageMimeType", () => {
|
||||||
it("accepts pi-supported image types", () => {
|
it("accepts pi-supported image types", () => {
|
||||||
@@ -43,12 +43,12 @@ describe("parsePromptAttachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes valid attachments", () => {
|
it("normalizes valid attachments", () => {
|
||||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]);
|
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]);
|
||||||
expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "shot.png" }]);
|
expect(result).toEqual([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "shot.png" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops empty names", () => {
|
it("drops empty names", () => {
|
||||||
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: tinyPngBase64, name: "" }]);
|
const result = parsePromptAttachments([{ kind: "image", mimeType: "image/png", data: validImageBase64, name: "" }]);
|
||||||
expect(result[0]).not.toHaveProperty("name");
|
expect(result[0]).not.toHaveProperty("name");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,9 +57,9 @@ describe("parsePromptAttachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsupported kinds and mime types", () => {
|
it("rejects unsupported kinds and mime types", () => {
|
||||||
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
|
expect(() => parsePromptAttachments([{ kind: "video", mimeType: "image/png", data: validImageBase64 }])).toThrow(/unsupported kind/);
|
||||||
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: tinyPngBase64 }])).toThrow(/unsupported kind/);
|
expect(() => parsePromptAttachments([{ kind: "file", mimeType: "application/pdf", data: validImageBase64 }])).toThrow(/unsupported kind/);
|
||||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }])).toThrow(/unsupported image type/);
|
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }])).toThrow(/unsupported image type/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts generic files only when file attachments are allowed", () => {
|
it("accepts generic files only when file attachments are allowed", () => {
|
||||||
@@ -83,7 +83,7 @@ describe("parsePromptAttachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps image MIME validation when file attachments are allowed", () => {
|
it("keeps image MIME validation when file attachments are allowed", () => {
|
||||||
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: tinyPngBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
|
expect(() => parsePromptAttachments([{ kind: "image", mimeType: "image/svg+xml", data: validImageBase64 }], { allowFileAttachments: true })).toThrow(/unsupported image type/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid base64 data", () => {
|
it("rejects invalid base64 data", () => {
|
||||||
@@ -97,7 +97,7 @@ describe("parsePromptAttachments", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("enforces the attachment count limit", () => {
|
it("enforces the attachment count limit", () => {
|
||||||
const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: tinyPngBase64 }));
|
const many = Array.from({ length: 3 }, () => ({ kind: "image", mimeType: "image/png", data: validImageBase64 }));
|
||||||
expect(() => parsePromptAttachments(many, { maxAttachments: 2 })).toThrow(/too many attachments/);
|
expect(() => parsePromptAttachments(many, { maxAttachments: 2 })).toThrow(/too many attachments/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user