mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-15 12:00:43 +00:00
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { resolveBrowserConfig } from "./config.js";
|
|
import {
|
|
allocateCdpPort,
|
|
allocateColor,
|
|
CDP_PORT_RANGE_END,
|
|
CDP_PORT_RANGE_START,
|
|
getUsedColors,
|
|
getUsedPorts,
|
|
isValidProfileName,
|
|
PROFILE_COLORS,
|
|
} from "./profiles.js";
|
|
|
|
describe("profile name validation", () => {
|
|
it.each(["openclaw", "work", "my-profile", "test123", "a", "a-b-c-1-2-3", "1test"])(
|
|
"accepts valid lowercase name: %s",
|
|
(name) => {
|
|
expect(isValidProfileName(name)).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("rejects empty or missing names", () => {
|
|
expect(isValidProfileName("")).toBe(false);
|
|
// @ts-expect-error testing invalid input
|
|
expect(isValidProfileName(null)).toBe(false);
|
|
// @ts-expect-error testing invalid input
|
|
expect(isValidProfileName(undefined)).toBe(false);
|
|
});
|
|
|
|
it("rejects names that are too long", () => {
|
|
const longName = "a".repeat(65);
|
|
expect(isValidProfileName(longName)).toBe(false);
|
|
|
|
const maxName = "a".repeat(64);
|
|
expect(isValidProfileName(maxName)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
"MyProfile",
|
|
"PROFILE",
|
|
"Work",
|
|
"my profile",
|
|
"my_profile",
|
|
"my.profile",
|
|
"my/profile",
|
|
"my@profile",
|
|
"-invalid",
|
|
"--double",
|
|
])("rejects invalid name: %s", (name) => {
|
|
expect(isValidProfileName(name)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("port allocation", () => {
|
|
it("allocates within an explicit range", () => {
|
|
const usedPorts = new Set<number>();
|
|
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20000);
|
|
usedPorts.add(20000);
|
|
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20001);
|
|
});
|
|
|
|
it("allocates next available port from default range", () => {
|
|
const cases = [
|
|
{ name: "none used", used: new Set<number>(), expected: CDP_PORT_RANGE_START },
|
|
{
|
|
name: "sequentially used start ports",
|
|
used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]),
|
|
expected: CDP_PORT_RANGE_START + 2,
|
|
},
|
|
{
|
|
name: "first gap wins",
|
|
used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 2]),
|
|
expected: CDP_PORT_RANGE_START + 1,
|
|
},
|
|
{
|
|
name: "ignores outside-range ports",
|
|
used: new Set([1, 2, 3, 50000]),
|
|
expected: CDP_PORT_RANGE_START,
|
|
},
|
|
] as const;
|
|
|
|
for (const testCase of cases) {
|
|
expect(allocateCdpPort(testCase.used), testCase.name).toBe(testCase.expected);
|
|
}
|
|
});
|
|
|
|
it("returns null when all ports are exhausted", () => {
|
|
const usedPorts = new Set<number>();
|
|
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
|
|
usedPorts.add(port);
|
|
}
|
|
expect(allocateCdpPort(usedPorts)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getUsedPorts", () => {
|
|
it("returns empty set for undefined profiles", () => {
|
|
expect(getUsedPorts(undefined)).toEqual(new Set());
|
|
});
|
|
|
|
it("extracts ports from profile configs", () => {
|
|
const profiles = {
|
|
openclaw: { cdpPort: 18792 },
|
|
work: { cdpPort: 18793 },
|
|
personal: { cdpPort: 18795 },
|
|
};
|
|
const used = getUsedPorts(profiles);
|
|
expect(used).toEqual(new Set([18792, 18793, 18795]));
|
|
});
|
|
|
|
it("extracts ports from cdpUrl when cdpPort is missing", () => {
|
|
const profiles = {
|
|
remote: { cdpUrl: "http://10.0.0.42:9222" },
|
|
secure: { cdpUrl: "https://example.com:9443" },
|
|
};
|
|
const used = getUsedPorts(profiles);
|
|
expect(used).toEqual(new Set([9222, 9443]));
|
|
});
|
|
|
|
it("ignores invalid cdpUrl values", () => {
|
|
const profiles = {
|
|
bad: { cdpUrl: "notaurl" },
|
|
};
|
|
const used = getUsedPorts(profiles);
|
|
expect(used.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("port collision prevention", () => {
|
|
it("raw config vs resolved config - shows the data source difference", () => {
|
|
// This demonstrates WHY the route handler must use resolved config
|
|
|
|
// Fresh config with no profiles defined (like a new install)
|
|
const rawConfigProfiles = undefined;
|
|
const usedFromRaw = getUsedPorts(rawConfigProfiles);
|
|
|
|
// Raw config shows empty - no ports used
|
|
expect(usedFromRaw.size).toBe(0);
|
|
|
|
// But resolved config has implicit openclaw at 18800
|
|
const resolved = resolveBrowserConfig({});
|
|
const usedFromResolved = getUsedPorts(resolved.profiles);
|
|
expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true);
|
|
});
|
|
|
|
it("create-profile must use resolved config to avoid port collision", () => {
|
|
// The route handler must use state.resolved.profiles, not raw config
|
|
|
|
// Simulate what happens with raw config (empty) vs resolved config
|
|
const rawConfig: { browser: { profiles?: Record<string, { cdpPort?: number }> } } = {
|
|
browser: {},
|
|
}; // Fresh config, no profiles
|
|
const buggyUsedPorts = getUsedPorts(rawConfig.browser?.profiles);
|
|
const buggyAllocatedPort = allocateCdpPort(buggyUsedPorts);
|
|
|
|
// Raw config: first allocation gets 18800
|
|
expect(buggyAllocatedPort).toBe(CDP_PORT_RANGE_START);
|
|
|
|
// Resolved config: includes implicit openclaw at 18800
|
|
const resolved = resolveBrowserConfig(
|
|
rawConfig.browser as Parameters<typeof resolveBrowserConfig>[0],
|
|
);
|
|
const fixedUsedPorts = getUsedPorts(resolved.profiles);
|
|
const fixedAllocatedPort = allocateCdpPort(fixedUsedPorts);
|
|
|
|
// Resolved: first NEW profile gets 18801, avoiding collision
|
|
expect(fixedAllocatedPort).toBe(CDP_PORT_RANGE_START + 1);
|
|
});
|
|
});
|
|
|
|
describe("color allocation", () => {
|
|
it("allocates next unused color from palette", () => {
|
|
const cases = [
|
|
{ name: "none used", used: new Set<string>(), expected: PROFILE_COLORS[0] },
|
|
{
|
|
name: "first color used",
|
|
used: new Set([PROFILE_COLORS[0].toUpperCase()]),
|
|
expected: PROFILE_COLORS[1],
|
|
},
|
|
{
|
|
name: "multiple used colors",
|
|
used: new Set([
|
|
PROFILE_COLORS[0].toUpperCase(),
|
|
PROFILE_COLORS[1].toUpperCase(),
|
|
PROFILE_COLORS[2].toUpperCase(),
|
|
]),
|
|
expected: PROFILE_COLORS[3],
|
|
},
|
|
] as const;
|
|
for (const testCase of cases) {
|
|
expect(allocateColor(testCase.used), testCase.name).toBe(testCase.expected);
|
|
}
|
|
});
|
|
|
|
it("handles case-insensitive color matching", () => {
|
|
const usedColors = new Set(["#ff4500"]); // lowercase
|
|
// Should still skip this color (case-insensitive)
|
|
// Note: allocateColor compares against uppercase, so lowercase won't match
|
|
// This tests the current behavior
|
|
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); // returns first since lowercase doesn't match
|
|
});
|
|
|
|
it("cycles when all colors are used", () => {
|
|
const usedColors = new Set(PROFILE_COLORS.map((c) => c.toUpperCase()));
|
|
// Should cycle based on count
|
|
const result = allocateColor(usedColors);
|
|
expect(PROFILE_COLORS).toContain(result);
|
|
});
|
|
|
|
it("cycles based on count when palette exhausted", () => {
|
|
// Add all colors plus some extras
|
|
const usedColors = new Set([
|
|
...PROFILE_COLORS.map((c) => c.toUpperCase()),
|
|
"#AAAAAA",
|
|
"#BBBBBB",
|
|
]);
|
|
const result = allocateColor(usedColors);
|
|
// Index should be (10 + 2) % 10 = 2
|
|
expect(result).toBe(PROFILE_COLORS[2]);
|
|
});
|
|
});
|
|
|
|
describe("getUsedColors", () => {
|
|
it("returns empty set when no color profiles are configured", () => {
|
|
expect(getUsedColors(undefined)).toEqual(new Set());
|
|
});
|
|
|
|
it("extracts and uppercases colors from profile configs", () => {
|
|
const profiles = {
|
|
openclaw: { color: "#ff4500" },
|
|
work: { color: "#0066CC" },
|
|
};
|
|
const used = getUsedColors(profiles);
|
|
expect(used).toEqual(new Set(["#FF4500", "#0066CC"]));
|
|
});
|
|
});
|