File size: 8,787 Bytes
fb4d8fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
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"]));
  });
});