Archived
refactor(test): move SettingsSessiondPanel assertions to a public seam
Migrate the prime Finding-2 offender off Lit TemplateResult scraping. The panel's dynamic notice-stack logic is extracted into an exported pure sessiondPanelNotices() (plus SessiondPanelNoticeContext) and the existing sessiondDescription is exported, so the test asserts notice composition/ordering and the scope description through public seams instead of flattening rendered template internals. Save and draft-preservation behavior are observed via the injected onSave callback and public state. Removes all per-file template-inspection helpers and the escape-hatch need for this file (testing-guide Findings 1-3).
This commit is contained in:
@@ -1,66 +1,77 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { TemplateResult } from "lit";
|
|
||||||
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||||
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
|
import { SettingsSessiondPanel, sessiondDescription, sessiondPanelNotices, type SessiondPanelNoticeContext } from "./SettingsSessiondPanel";
|
||||||
import type { SettingsNotice } from "./SettingsPanelFrame";
|
|
||||||
|
|
||||||
describe("settings-sessiond-panel layout", () => {
|
// This suite asserts the session-daemon panel's dynamic behavior through public
|
||||||
it("names the selected machine in the scope and restart notice when config is available", () => {
|
// seams rather than by inspecting rendered Lit `TemplateResult` internals:
|
||||||
const panel = new SettingsSessiondPanel();
|
// notice composition/ordering and the description string come from the exported
|
||||||
panel.targetLabel = "Lab Mac (remote machine)";
|
// `sessiondPanelNotices`/`sessiondDescription` helpers, and profile-save and
|
||||||
setPanelConfig(panel, configResponse({
|
// draft-preservation behavior are observed via injected callbacks and public
|
||||||
|
// state. Static labels and layout are intentionally not asserted here (no DOM
|
||||||
|
// harness); per the testing-guide skill those are not verified by scraping
|
||||||
|
// template internals.
|
||||||
|
|
||||||
|
describe("session daemon panel notices", () => {
|
||||||
|
it("names the selected machine in the scope description and restart notice", () => {
|
||||||
|
const targetLabel = "Lab Mac (remote machine)";
|
||||||
|
const config = configResponse({
|
||||||
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
agent: { command: "agent-lab", dir: "/srv/agent-lab" },
|
||||||
spawnSessions: true,
|
spawnSessions: true,
|
||||||
subsessions: false,
|
subsessions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessiondDescription(targetLabel)).toContain("Lab Mac (remote machine)");
|
||||||
|
|
||||||
|
const notices = sessiondPanelNotices(config, noticeContext({
|
||||||
|
activeProfile: activeProfile("pi", "/srv/pi"),
|
||||||
|
targetLabel,
|
||||||
}));
|
}));
|
||||||
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
|
|
||||||
|
|
||||||
const rendered = flattenTemplateContent(panel.render());
|
expect(notices).toHaveLength(1);
|
||||||
|
expect(notices[0]?.type).toBe("warning");
|
||||||
|
expect(notices[0]?.title).toBe("Pi-compatible agent profile restart required on Lab Mac (remote machine)");
|
||||||
|
expect(notices[0]?.content).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
expectTextOrder(rendered, [
|
it("orders save/load notices before the restart notice", () => {
|
||||||
"Session daemon",
|
const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false });
|
||||||
"Select the Pi-compatible agent profile and companion CLI for Lab Mac (remote machine).",
|
|
||||||
"Reload",
|
const notices = sessiondPanelNotices(config, noticeContext({
|
||||||
"Pi-compatible agent profile restart required on Lab Mac (remote machine)",
|
activeProfile: activeProfile("pi", "/srv/pi"),
|
||||||
"Run <code>pi-web restart</code> on that machine",
|
error: "Failed to save session-daemon config.",
|
||||||
"Config file",
|
savedMessage: "Session daemon settings saved.",
|
||||||
"Companion CLI command",
|
}));
|
||||||
"agent-lab",
|
|
||||||
"Profile state directory",
|
expect(notices.map((notice) => notice.type)).toEqual(["error", "success", "warning"]);
|
||||||
"/srv/agent-lab",
|
expect(notices[0]?.content).toBe("Failed to save session-daemon config.");
|
||||||
"Allow agents to start sessions",
|
expect(notices[1]?.content).toBe("Session daemon settings saved.");
|
||||||
|
expect(notices[2]?.title).toBe("Pi-compatible agent profile restart required on local (local gateway)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds no restart or activation guidance when the desired and active profiles match", () => {
|
||||||
|
const config = configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
|
||||||
|
|
||||||
|
const notices = sessiondPanelNotices(config, noticeContext({
|
||||||
|
activeProfile: activeProfile("agent-lab", "/srv/agent-lab"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(notices).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports only the blocking error and no activation guidance when config is unavailable", () => {
|
||||||
|
const notices = sessiondPanelNotices(undefined, noticeContext({
|
||||||
|
activeProfile: undefined,
|
||||||
|
error: "Selected-machine settings are not available on Lab Mac.",
|
||||||
|
targetLabel: "Lab Mac (remote machine)",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(notices).toEqual([
|
||||||
|
{ type: "error", content: "Selected-machine settings are not available on Lab Mac." },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("orders save/load notices before the restart notice and settings content", () => {
|
describe("session daemon panel save behavior", () => {
|
||||||
const panel = new SettingsSessiondPanel();
|
|
||||||
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" }, spawnSessions: false }));
|
|
||||||
panel.activeAgentProfile = activeProfile("pi", "/srv/pi");
|
|
||||||
panel.error = "Failed to save session-daemon config.";
|
|
||||||
panel.savedMessage = "Session daemon settings saved.";
|
|
||||||
|
|
||||||
const rendered = flattenTemplateContent(panel.render());
|
|
||||||
|
|
||||||
expectTextOrder(rendered, [
|
|
||||||
"Failed to save session-daemon config.",
|
|
||||||
"Session daemon settings saved.",
|
|
||||||
"Pi-compatible agent profile restart required on local (local gateway)",
|
|
||||||
"Config file",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the profile as active without restart guidance when desired and active match", () => {
|
|
||||||
const panel = new SettingsSessiondPanel();
|
|
||||||
setPanelConfig(panel, configResponse({ agent: { command: "agent-lab", dir: "/srv/agent-lab" } }));
|
|
||||||
panel.activeAgentProfile = activeProfile("agent-lab", "/srv/agent-lab");
|
|
||||||
|
|
||||||
const rendered = flattenTemplateContent(panel.render());
|
|
||||||
|
|
||||||
expect(rendered).toContain("Profile status");
|
|
||||||
expect(rendered).toContain("Active");
|
|
||||||
expect(rendered).not.toContain("restart required on");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("submits command and directory together as one profile save", async () => {
|
it("submits command and directory together as one profile save", async () => {
|
||||||
const panel = new SettingsSessiondPanel();
|
const panel = new SettingsSessiondPanel();
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
@@ -92,25 +103,19 @@ describe("settings-sessiond-panel layout", () => {
|
|||||||
callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]]));
|
callPanelMethod(panel, "willUpdate", new Map([["configResponse", toggled]]));
|
||||||
expect(Reflect.get(panel, "agentDraftDirty")).toBe(false);
|
expect(Reflect.get(panel, "agentDraftDirty")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows one blocked content state without restart guidance or toggles when config is unavailable", () => {
|
|
||||||
const panel = new SettingsSessiondPanel();
|
|
||||||
panel.targetLabel = "Lab Mac (remote machine)";
|
|
||||||
panel.error = "Selected-machine settings are not available on Lab Mac.";
|
|
||||||
|
|
||||||
const rendered = flattenTemplateContent(panel.render());
|
|
||||||
|
|
||||||
expectTextOrder(rendered, [
|
|
||||||
"Selected-machine settings are not available on Lab Mac.",
|
|
||||||
"Configuration is unavailable. Reload to try again.",
|
|
||||||
]);
|
|
||||||
expect(countOccurrences(rendered, "Configuration is unavailable. Reload to try again.")).toBe(1);
|
|
||||||
expect(rendered).not.toContain("Restart required on");
|
|
||||||
expect(rendered).not.toContain("Allow agents to start sessions");
|
|
||||||
expect(rendered).not.toContain("Effective after environment overrides");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function noticeContext(overrides: Partial<SessiondPanelNoticeContext>): SessiondPanelNoticeContext {
|
||||||
|
return {
|
||||||
|
error: "",
|
||||||
|
savedMessage: "",
|
||||||
|
activeProfile: undefined,
|
||||||
|
targetLabel: "local (local gateway)",
|
||||||
|
profileEditingSupported: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor {
|
function activeProfile(command: string, dir: string): ActiveAgentProfileDescriptor {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -142,81 +147,6 @@ function callPanelMethod(panel: SettingsSessiondPanel, methodName: string, ...ar
|
|||||||
return Reflect.apply(method, panel, args);
|
return Reflect.apply(method, panel, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenTemplateContent(template: TemplateResult): string {
|
|
||||||
const chunks: string[] = [];
|
|
||||||
visitTemplate(template);
|
|
||||||
return chunks.join("");
|
|
||||||
|
|
||||||
function visitTemplate(current: TemplateResult): void {
|
|
||||||
const strings = templateStrings(current);
|
|
||||||
const values = templateValues(current);
|
|
||||||
for (let index = 0; index < values.length; index += 1) {
|
|
||||||
const staticChunk = strings[index];
|
|
||||||
if (staticChunk !== undefined) chunks.push(staticChunk);
|
|
||||||
visitValue(values[index]);
|
|
||||||
}
|
|
||||||
const finalChunk = strings[values.length];
|
|
||||||
if (finalChunk !== undefined) chunks.push(finalChunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
function visitValue(value: unknown): void {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) visitValue(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isSettingsNotice(value)) {
|
|
||||||
visitValue(value.title);
|
|
||||||
visitValue(value.content);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isTemplateResult(value)) {
|
|
||||||
visitTemplate(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
||||||
chunks.push(String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectTextOrder(content: string, labels: readonly string[]): void {
|
|
||||||
let previousIndex = -1;
|
|
||||||
for (const label of labels) {
|
|
||||||
const currentIndex = content.indexOf(label, previousIndex + 1);
|
|
||||||
if (currentIndex === -1) throw new Error(`Expected rendered content to include ${label}`);
|
|
||||||
expect(currentIndex).toBeGreaterThan(previousIndex);
|
|
||||||
previousIndex = currentIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countOccurrences(content: string, needle: string): number {
|
|
||||||
return content.split(needle).length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateStrings(template: TemplateResult): readonly string[] {
|
|
||||||
const strings = Reflect.get(template, "strings");
|
|
||||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
|
||||||
return strings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
|
||||||
const values = Reflect.get(template, "values");
|
|
||||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
|
||||||
return values.map((value: unknown) => value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
|
||||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSettingsNotice(value: unknown): value is SettingsNotice {
|
|
||||||
return typeof value === "object" && value !== null && typeof Reflect.get(value, "type") === "string" && Reflect.has(value, "content");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStringArray(value: unknown): value is string[] {
|
|
||||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||||
return {
|
return {
|
||||||
path: "/tmp/pi-web/config.json",
|
path: "/tmp/pi-web/config.json",
|
||||||
|
|||||||
@@ -159,25 +159,13 @@ export class SettingsSessiondPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
|
private panelNotices(config: PiWebConfigResponse | undefined): readonly SettingsNotice[] {
|
||||||
const notices: SettingsNotice[] = [];
|
return sessiondPanelNotices(config, {
|
||||||
const error = this.agentLocalError || this.error;
|
error: this.agentLocalError || this.error,
|
||||||
if (error !== "") notices.push({ type: "error", content: error });
|
savedMessage: this.savedMessage,
|
||||||
if (this.savedMessage !== "") notices.push({ type: "success", content: this.savedMessage });
|
activeProfile: this.activeAgentProfile,
|
||||||
const activation = agentProfileActivationState(config, this.activeAgentProfile);
|
targetLabel: this.targetLabel,
|
||||||
if (activation === "restart-required") {
|
profileEditingSupported: this.agentProfileSupport.state === "supported",
|
||||||
notices.push({
|
|
||||||
type: "warning",
|
|
||||||
title: `Pi-compatible agent profile restart required on ${this.targetLabel}`,
|
|
||||||
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
|
|
||||||
});
|
});
|
||||||
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
|
|
||||||
notices.push({
|
|
||||||
type: "info",
|
|
||||||
title: `Active Pi-compatible agent profile unavailable on ${this.targetLabel}`,
|
|
||||||
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return notices;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderUnavailableConfigState(): TemplateResult {
|
private renderUnavailableConfigState(): TemplateResult {
|
||||||
@@ -273,6 +261,44 @@ function errorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessiondDescription(targetLabel: string): string {
|
export function sessiondDescription(targetLabel: string): string {
|
||||||
return `Select the Pi-compatible agent profile and companion CLI for ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
|
return `Select the Pi-compatible agent profile and companion CLI for ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessiondPanelNoticeContext {
|
||||||
|
readonly error: string;
|
||||||
|
readonly savedMessage: string;
|
||||||
|
readonly activeProfile: ActiveAgentProfileDescriptor | undefined;
|
||||||
|
readonly targetLabel: string;
|
||||||
|
readonly profileEditingSupported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the session-daemon panel's notice stack (error, saved, and
|
||||||
|
* profile-activation guidance) as a pure, publicly testable seam so tests assert
|
||||||
|
* the dynamic notice logic and ordering here instead of scraping rendered
|
||||||
|
* `TemplateResult` internals.
|
||||||
|
*/
|
||||||
|
export function sessiondPanelNotices(
|
||||||
|
config: PiWebConfigResponse | undefined,
|
||||||
|
context: SessiondPanelNoticeContext,
|
||||||
|
): readonly SettingsNotice[] {
|
||||||
|
const notices: SettingsNotice[] = [];
|
||||||
|
if (context.error !== "") notices.push({ type: "error", content: context.error });
|
||||||
|
if (context.savedMessage !== "") notices.push({ type: "success", content: context.savedMessage });
|
||||||
|
const activation = agentProfileActivationState(config, context.activeProfile);
|
||||||
|
if (activation === "restart-required") {
|
||||||
|
notices.push({
|
||||||
|
type: "warning",
|
||||||
|
title: `Pi-compatible agent profile restart required on ${context.targetLabel}`,
|
||||||
|
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
|
||||||
|
});
|
||||||
|
} else if (config !== undefined && activation === "unavailable" && context.profileEditingSupported) {
|
||||||
|
notices.push({
|
||||||
|
type: "info",
|
||||||
|
title: `Active Pi-compatible agent profile unavailable on ${context.targetLabel}`,
|
||||||
|
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return notices;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user