273 lines
9.0 KiB
TypeScript
273 lines
9.0 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
handleOnboardService,
|
|
handoffToOnboardedService,
|
|
isInstallableReleaseVersion,
|
|
resolveOnboardServiceDashboardUrl,
|
|
shouldOfferForegroundStart,
|
|
} from "../onboard-service.js";
|
|
|
|
function dashboardConfig(overrides: {
|
|
host?: string;
|
|
port?: number;
|
|
baseUrlMode?: "auto" | "explicit";
|
|
publicBaseUrl?: string;
|
|
} = {}) {
|
|
return {
|
|
server: {
|
|
host: overrides.host ?? "127.0.0.1",
|
|
port: overrides.port ?? 3100,
|
|
},
|
|
auth: {
|
|
baseUrlMode: overrides.baseUrlMode ?? "auto",
|
|
disableSignUp: false,
|
|
...(overrides.publicBaseUrl ? { publicBaseUrl: overrides.publicBaseUrl } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function supportedDetection() {
|
|
return {
|
|
supported: true as const,
|
|
manager: {
|
|
platform: "systemd" as const,
|
|
instanceId: "default",
|
|
serviceName: "paperclipai.service",
|
|
definitionPath: "/tmp/paperclipai.service",
|
|
renderDefinition: () => "unit",
|
|
install: vi.fn(async () => ({ changed: true })),
|
|
uninstall: vi.fn(async () => undefined),
|
|
start: vi.fn(async () => undefined),
|
|
stop: vi.fn(async () => undefined),
|
|
restart: vi.fn(async () => undefined),
|
|
status: vi.fn(async () => ({
|
|
platform: "systemd" as const,
|
|
serviceName: "paperclipai.service",
|
|
installed: true,
|
|
active: true,
|
|
enabled: true,
|
|
pid: 123,
|
|
})),
|
|
logs: vi.fn(async () => undefined),
|
|
installedExecutablePath: vi.fn(async () => null),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("onboard service policy", () => {
|
|
it("does not install during --yes onboarding without opt-in", async () => {
|
|
const detection = supportedDetection();
|
|
const info = vi.fn();
|
|
|
|
const installed = await handleOnboardService(
|
|
{ yes: true },
|
|
{ detect: vi.fn(async () => detection), isInteractive: () => false, info },
|
|
);
|
|
|
|
expect(installed).toBe(false);
|
|
expect(detection.manager.install).not.toHaveBeenCalled();
|
|
expect(info).toHaveBeenCalledWith(expect.stringContaining("--install-service"));
|
|
});
|
|
|
|
it("installs when --yes explicitly opts in", async () => {
|
|
const detection = supportedDetection();
|
|
|
|
const installed = await handleOnboardService(
|
|
{ yes: true, installService: true },
|
|
{
|
|
detect: vi.fn(async () => detection),
|
|
isInteractive: () => false,
|
|
ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })),
|
|
},
|
|
);
|
|
|
|
expect(installed).toBe(true);
|
|
expect(detection.manager.install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true });
|
|
});
|
|
|
|
it("asks during interactive onboarding", async () => {
|
|
const detection = supportedDetection();
|
|
const confirm = vi.fn(async () => true);
|
|
|
|
const installed = await handleOnboardService(
|
|
{},
|
|
{
|
|
detect: vi.fn(async () => detection),
|
|
isInteractive: () => true,
|
|
confirm,
|
|
ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })),
|
|
},
|
|
);
|
|
|
|
expect(confirm).toHaveBeenCalledOnce();
|
|
expect(installed).toBe(true);
|
|
});
|
|
|
|
it("silences the hint with --no-install-service", async () => {
|
|
const info = vi.fn();
|
|
const detect = vi.fn(async () => supportedDetection());
|
|
|
|
const installed = await handleOnboardService(
|
|
{ yes: true, installService: false },
|
|
{ detect, isInteractive: () => false, info },
|
|
);
|
|
|
|
expect(installed).toBe(false);
|
|
expect(detect).not.toHaveBeenCalled();
|
|
expect(info).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("materializes the managed shim before installing the service", async () => {
|
|
const detection = supportedDetection();
|
|
const success = vi.fn();
|
|
const ensureServiceShim = vi.fn(async () => ({ ok: true, installedNow: true }));
|
|
|
|
const installed = await handleOnboardService(
|
|
{ yes: true, installService: true },
|
|
{ detect: vi.fn(async () => detection), isInteractive: () => false, ensureServiceShim, success },
|
|
);
|
|
|
|
expect(installed).toBe(true);
|
|
expect(ensureServiceShim).toHaveBeenCalledOnce();
|
|
expect(success).toHaveBeenCalledWith(expect.stringContaining("managed paperclipai payload"));
|
|
expect(detection.manager.install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true });
|
|
});
|
|
|
|
it("declines instead of installing a service without a binary", async () => {
|
|
const detection = supportedDetection();
|
|
const warn = vi.fn();
|
|
|
|
const installed = await handleOnboardService(
|
|
{ yes: true, installService: true },
|
|
{
|
|
detect: vi.fn(async () => detection),
|
|
isInteractive: () => false,
|
|
ensureServiceShim: vi.fn(async () => ({ ok: false, installedNow: false, reason: "npm exploded" })),
|
|
warn,
|
|
},
|
|
);
|
|
|
|
expect(installed).toBe(false);
|
|
expect(detection.manager.install).not.toHaveBeenCalled();
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("npm exploded"));
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("paperclipai install"));
|
|
});
|
|
|
|
});
|
|
|
|
describe("isInstallableReleaseVersion", () => {
|
|
it("accepts calendar releases and rejects placeholders", () => {
|
|
expect(isInstallableReleaseVersion("2026.824.1")).toBe(true);
|
|
expect(isInstallableReleaseVersion("2026.818.0-beta.1")).toBe(true);
|
|
expect(isInstallableReleaseVersion("0.3.1")).toBe(false);
|
|
expect(isInstallableReleaseVersion("not-a-version")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("onboarded service dashboard handoff", () => {
|
|
it("resolves a reachable local dashboard URL", () => {
|
|
expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ host: "0.0.0.0", port: 4321 })))
|
|
.toBe("http://127.0.0.1:4321");
|
|
expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ host: "::1" })))
|
|
.toBe("http://[::1]:3100");
|
|
});
|
|
|
|
it("uses the configured public URL when auth requires one", () => {
|
|
expect(resolveOnboardServiceDashboardUrl(dashboardConfig({
|
|
baseUrlMode: "explicit",
|
|
publicBaseUrl: "https://paperclip.example.com/",
|
|
}))).toBe("https://paperclip.example.com");
|
|
});
|
|
|
|
it("prints the dashboard URL without opening a browser in non-interactive runs", async () => {
|
|
const info = vi.fn();
|
|
const waitUntilReady = vi.fn(async () => ({
|
|
schemaVersion: 1 as const,
|
|
instanceId: "default",
|
|
pid: 123,
|
|
host: "127.0.0.1",
|
|
port: 3100,
|
|
dashboardUrl: "http://127.0.0.1:3100",
|
|
startedAt: "2026-08-25T00:00:00.000Z",
|
|
}));
|
|
const openDashboard = vi.fn(async () => true);
|
|
|
|
await handoffToOnboardedService(dashboardConfig(), {
|
|
isInteractive: () => false,
|
|
waitUntilReady,
|
|
openDashboard,
|
|
info,
|
|
});
|
|
|
|
expect(info).toHaveBeenCalledWith(expect.stringContaining("http://127.0.0.1:3100"));
|
|
expect(waitUntilReady).toHaveBeenCalledOnce();
|
|
expect(openDashboard).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the ready service runtime port before opening the dashboard", async () => {
|
|
const waitUntilReady = vi.fn(async () => ({
|
|
schemaVersion: 1 as const,
|
|
instanceId: "default",
|
|
pid: 123,
|
|
host: "127.0.0.1",
|
|
port: 3101,
|
|
dashboardUrl: "http://127.0.0.1:3101",
|
|
startedAt: "2026-08-25T00:00:00.000Z",
|
|
}));
|
|
const openDashboard = vi.fn(async () => true);
|
|
const success = vi.fn();
|
|
|
|
await handoffToOnboardedService(dashboardConfig(), {
|
|
isInteractive: () => true,
|
|
waitUntilReady,
|
|
openDashboard,
|
|
info: vi.fn(),
|
|
success,
|
|
});
|
|
|
|
expect(waitUntilReady).toHaveBeenCalledOnce();
|
|
expect(openDashboard).toHaveBeenCalledWith("http://127.0.0.1:3101");
|
|
expect(success).toHaveBeenCalledWith(expect.stringContaining("Sent"));
|
|
});
|
|
|
|
it("keeps the manual link and warns when service health does not become ready", async () => {
|
|
const openDashboard = vi.fn(async () => true);
|
|
const warn = vi.fn();
|
|
|
|
await handoffToOnboardedService(dashboardConfig(), {
|
|
isInteractive: () => true,
|
|
waitUntilReady: vi.fn(async () => null),
|
|
openDashboard,
|
|
info: vi.fn(),
|
|
warn,
|
|
});
|
|
|
|
expect(openDashboard).not.toHaveBeenCalled();
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("paperclipai service logs"));
|
|
});
|
|
});
|
|
|
|
describe("shouldOfferForegroundStart", () => {
|
|
const base = { serviceInstalled: false, startAlreadyDecided: false, invokedByRun: false, interactive: true };
|
|
|
|
it("offers a foreground start on a plain interactive onboard", () => {
|
|
expect(shouldOfferForegroundStart(base)).toBe(true);
|
|
});
|
|
|
|
it("never prompts after the service was installed and started", () => {
|
|
expect(shouldOfferForegroundStart({ ...base, serviceInstalled: true })).toBe(false);
|
|
});
|
|
|
|
it("never prompts when the start decision was already made by flags", () => {
|
|
expect(shouldOfferForegroundStart({ ...base, startAlreadyDecided: true })).toBe(false);
|
|
});
|
|
|
|
it("never prompts when run itself invoked onboarding", () => {
|
|
expect(shouldOfferForegroundStart({ ...base, invokedByRun: true })).toBe(false);
|
|
});
|
|
|
|
it("never prompts without an interactive terminal", () => {
|
|
expect(shouldOfferForegroundStart({ ...base, interactive: false })).toBe(false);
|
|
});
|
|
});
|