Spaces:
Paused
Paused
| import { describe, expect, it } from "vitest"; | |
| import { | |
| allocateCdpPort, | |
| allocateColor, | |
| CDP_PORT_RANGE_END, | |
| CDP_PORT_RANGE_START, | |
| getUsedColors, | |
| getUsedPorts, | |
| isValidProfileName, | |
| PROFILE_COLORS, | |
| } from "./profiles.js"; | |
| describe("profile name validation", () => { | |
| it("accepts valid lowercase names", () => { | |
| expect(isValidProfileName("openclaw")).toBe(true); | |
| expect(isValidProfileName("work")).toBe(true); | |
| expect(isValidProfileName("my-profile")).toBe(true); | |
| expect(isValidProfileName("test123")).toBe(true); | |
| expect(isValidProfileName("a")).toBe(true); | |
| expect(isValidProfileName("a-b-c-1-2-3")).toBe(true); | |
| expect(isValidProfileName("1test")).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("rejects uppercase letters", () => { | |
| expect(isValidProfileName("MyProfile")).toBe(false); | |
| expect(isValidProfileName("PROFILE")).toBe(false); | |
| expect(isValidProfileName("Work")).toBe(false); | |
| }); | |
| it("rejects spaces and special characters", () => { | |
| expect(isValidProfileName("my profile")).toBe(false); | |
| expect(isValidProfileName("my_profile")).toBe(false); | |
| expect(isValidProfileName("my.profile")).toBe(false); | |
| expect(isValidProfileName("my/profile")).toBe(false); | |
| expect(isValidProfileName("my@profile")).toBe(false); | |
| }); | |
| it("rejects names starting with hyphen", () => { | |
| expect(isValidProfileName("-invalid")).toBe(false); | |
| expect(isValidProfileName("--double")).toBe(false); | |
| }); | |
| }); | |
| describe("port allocation", () => { | |
| it("allocates first port when none used", () => { | |
| const usedPorts = new Set<number>(); | |
| expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START); | |
| }); | |
| 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("skips used ports and returns next available", () => { | |
| const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]); | |
| expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2); | |
| }); | |
| it("finds first gap in used ports", () => { | |
| const usedPorts = new Set([ | |
| CDP_PORT_RANGE_START, | |
| CDP_PORT_RANGE_START + 2, // gap at +1 | |
| ]); | |
| expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 1); | |
| }); | |
| 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(); | |
| }); | |
| it("handles ports outside range in used set", () => { | |
| const usedPorts = new Set([1, 2, 3, 50000]); // ports outside range | |
| expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START); | |
| }); | |
| }); | |
| describe("getUsedPorts", () => { | |
| it("returns empty set for undefined profiles", () => { | |
| expect(getUsedPorts(undefined)).toEqual(new Set()); | |
| }); | |
| it("returns empty set for empty profiles object", () => { | |
| expect(getUsedPorts({})).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", async () => { | |
| // This demonstrates WHY the route handler must use resolved config | |
| const { resolveBrowserConfig } = await import("./config.js"); | |
| // 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", async () => { | |
| // The route handler must use state.resolved.profiles, not raw config | |
| const { resolveBrowserConfig } = await import("./config.js"); | |
| // Simulate what happens with raw config (empty) vs resolved config | |
| const rawConfig = { 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); | |
| 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 first color when none used", () => { | |
| const usedColors = new Set<string>(); | |
| expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); | |
| }); | |
| it("allocates next unused color from palette", () => { | |
| // biome-ignore lint/style/noNonNullAssertion: Test file with known array | |
| const usedColors = new Set([PROFILE_COLORS[0].toUpperCase()]); | |
| expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[1]); | |
| }); | |
| it("skips multiple used colors", () => { | |
| const usedColors = new Set([ | |
| // biome-ignore lint/style/noNonNullAssertion: Test file with known array | |
| PROFILE_COLORS[0].toUpperCase(), | |
| // biome-ignore lint/style/noNonNullAssertion: Test file with known array | |
| PROFILE_COLORS[1].toUpperCase(), | |
| // biome-ignore lint/style/noNonNullAssertion: Test file with known array | |
| PROFILE_COLORS[2].toUpperCase(), | |
| ]); | |
| expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[3]); | |
| }); | |
| 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 for undefined profiles", () => { | |
| expect(getUsedColors(undefined)).toEqual(new Set()); | |
| }); | |
| it("returns empty set for empty profiles object", () => { | |
| expect(getUsedColors({})).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"])); | |
| }); | |
| }); | |