@harness-mix/cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/NOTICE +14 -0
  3. package/README.md +4 -24
  4. package/docs/multi-agent-collaboration.md +1 -1
  5. package/licenses/anthropic-codex-theme-MIT.txt +21 -0
  6. package/licenses/codex-dream-skin-MIT.txt +21 -0
  7. package/output/native-build/renderer-extension.js +703 -58
  8. package/package.json +192 -189
  9. package/scripts/collaboration-test.cjs +262 -200
  10. package/scripts/collaboration-ui-smoke.cjs +85 -77
  11. package/scripts/core-concurrency-test.cjs +23 -1
  12. package/scripts/e2e-pi-cancel.cjs +99 -0
  13. package/scripts/native-file-change-test.cjs +42 -2
  14. package/scripts/native-sidebar-test.cjs +8 -0
  15. package/scripts/native-vendor-adapters-test.cjs +67 -1
  16. package/scripts/pets-test.cjs +63 -0
  17. package/scripts/pi-cancel-race-test.cjs +102 -0
  18. package/scripts/stuck-turn-test.cjs +73 -0
  19. package/scripts/workspace-test.cjs +9 -0
  20. package/src/assets/skins/dream-skin/NOTICE.md +13 -0
  21. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/hero.webp +0 -0
  22. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/theme.json +34 -0
  23. package/src/main/adapters/claude.js +9 -6
  24. package/src/main/adapters/pi-family.js +41 -8
  25. package/src/main/harness-adapter/event-normalizer.js +270 -258
  26. package/src/main/host/collaboration-mcp.cjs +8 -1
  27. package/src/main/host/collaboration-tools.js +1 -1
  28. package/src/main/host/collaboration.js +81 -11
  29. package/src/main/host/runtime.js +1462 -1341
  30. package/src/main/native/pets.js +79 -2
  31. package/src/main/native/protocol.js +1164 -1145
  32. package/src/main/native/thread-list.js +6 -1
  33. package/src/main/workspace/core-review.js +75 -0
  34. package/src/main/workspace/diff.js +5 -1
  35. package/src/main/workspace/file-changes.js +9 -4
  36. package/src/main/workspace/review-controller.js +115 -102
  37. package/src/main/workspace/review.js +90 -88
  38. package/src/native-ui/renderer-extension/dist/contract-audit.js +16637 -0
  39. package/src/native-ui/renderer-extension/dist/index.js +34853 -0
  40. package/src/native-ui/renderer-extension/dist/production.js +34263 -0
  41. package/src/native-ui/renderer-extension/dist/renderer-binding-probe.js +34254 -0
  42. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  43. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +11 -0
  44. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  45. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts +73 -0
  46. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts.map +1 -0
  47. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +1 -0
  48. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  49. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  50. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts +12 -0
  51. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts.map +1 -0
  52. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  53. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  54. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  55. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
  56. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  57. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts +1 -1
  58. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts.map +1 -1
  59. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts +9 -0
  60. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts.map +1 -1
  61. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -1
  63. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  64. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3181 -3171
  65. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +14 -0
  66. package/src/native-ui/renderer-extension/src/renderer-pet-switcher.ts +308 -0
  67. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
  68. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +76 -21
  69. package/src/native-ui/renderer-extension/src/settings/collaboration-page.ts +139 -0
  70. package/src/native-ui/renderer-extension/src/settings/icons.ts +155 -153
  71. package/src/native-ui/renderer-extension/src/settings/localization.ts +785 -783
  72. package/src/native-ui/renderer-extension/src/settings/pages.ts +694 -681
  73. package/src/native-ui/renderer-extension/src/settings/pet-market.css +110 -1
  74. package/src/native-ui/renderer-extension/src/settings/pet-market.ts +276 -22
  75. package/src/native-ui/renderer-extension/src/settings/pets-client.ts +32 -0
  76. package/src/native-ui/renderer-extension/src/settings/shell.css +2294 -2255
  77. package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +51 -1
  78. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
  79. package/src/native-ui/renderer-extension/test/renderer-pet-switcher.test.ts +400 -0
  80. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +80 -79
  81. package/src/native-ui/renderer-extension/test/settings/pet-market.test.ts +378 -0
  82. package/src/native-ui/renderer-extension/test/settings/pets-client.test.ts +83 -0
  83. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
  84. package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +15 -1
@@ -0,0 +1,378 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("../../src/settings/icons.js", () => ({
4
+ createRendererSettingsIcon: () => "icon",
5
+ isRendererSettingsIconName: () => true,
6
+ }));
7
+
8
+ // The market page drives the official Codex pet UI through this module; tests mock it.
9
+ const switchCalls: Array<{ id: string; displayName: string }> = [];
10
+ let switchBehavior: (target: { id: string; displayName: string }) => Promise<void> = async () => {};
11
+ vi.mock("../../src/renderer-pet-switcher.js", () => ({
12
+ switchOfficialCodexPet: (target: { id: string; displayName: string }) => {
13
+ switchCalls.push(target);
14
+ return switchBehavior(target);
15
+ },
16
+ }));
17
+
18
+ import { createPetSettingsPage } from "../../src/settings/pet-market.js";
19
+ import { rendererSettingsMessages } from "../../src/settings/localization.js";
20
+ import type { RendererPetsClient } from "../../src/settings/pets-client.js";
21
+
22
+ class FakeElement {
23
+ readonly children: unknown[] = [];
24
+ readonly dataset: Record<string, string> = {};
25
+ readonly style: Record<string, string> = {};
26
+ readonly listeners = new Map<string, Array<() => void>>();
27
+ className = "";
28
+ textContent = "";
29
+ title = "";
30
+ type = "";
31
+ placeholder = "";
32
+ value = "";
33
+ disabled = false;
34
+ parent: FakeElement | null = null;
35
+
36
+ constructor(
37
+ readonly tagName: string,
38
+ readonly ownerDocument: FakeDocument,
39
+ ) {}
40
+
41
+ get isConnected(): boolean {
42
+ let node: FakeElement | null = this;
43
+ while (node?.parent) node = node.parent;
44
+ return node === this.ownerDocument.contentRoot || node === this;
45
+ }
46
+
47
+ append(...children: unknown[]): void {
48
+ for (const child of children) {
49
+ if (child instanceof FakeElement) child.parent = this;
50
+ }
51
+ this.children.push(...children);
52
+ }
53
+
54
+ replaceChildren(...children: unknown[]): void {
55
+ for (const child of this.children) {
56
+ if (child instanceof FakeElement && child.parent === this) child.parent = null;
57
+ }
58
+ this.children.splice(0, this.children.length);
59
+ this.append(...children);
60
+ }
61
+
62
+ addEventListener(name: string, listener: () => void): void {
63
+ const list = this.listeners.get(name) ?? [];
64
+ list.push(listener);
65
+ this.listeners.set(name, list);
66
+ }
67
+
68
+ removeEventListener(name: string, listener: () => void): void {
69
+ const list = this.listeners.get(name) ?? [];
70
+ this.listeners.set(
71
+ name,
72
+ list.filter((candidate) => candidate !== listener),
73
+ );
74
+ }
75
+
76
+ dispatch(name: string): void {
77
+ for (const listener of this.listeners.get(name) ?? []) listener();
78
+ }
79
+
80
+ closest(selector: string): FakeElement | null {
81
+ const classNames = selector.split(",").map((part) => part.trim().replace(/^\./, ""));
82
+ let node: FakeElement | null = this;
83
+ while (node) {
84
+ const nodeClasses = node.className.split(" ");
85
+ if (classNames.some((className) => nodeClasses.includes(className))) return node;
86
+ node = node.parent;
87
+ }
88
+ return null;
89
+ }
90
+ }
91
+
92
+ class FakeDocument {
93
+ readonly defaultView = {
94
+ navigator: { platform: "Win32", clipboard: { writeText: async () => undefined } },
95
+ };
96
+ contentRoot: FakeElement | null = null;
97
+
98
+ createElement(tagName: string): FakeElement {
99
+ return new FakeElement(tagName, this);
100
+ }
101
+ }
102
+
103
+ function descendants(root: FakeElement): FakeElement[] {
104
+ return [
105
+ root,
106
+ ...root.children.flatMap((child) => (child instanceof FakeElement ? descendants(child) : [])),
107
+ ];
108
+ }
109
+
110
+ function elementsWithClass(root: FakeElement, className: string): FakeElement[] {
111
+ return descendants(root).filter((candidate) =>
112
+ candidate.className.split(" ").includes(className),
113
+ );
114
+ }
115
+
116
+ function buttonsWithLabel(root: FakeElement, label: string): FakeElement[] {
117
+ return descendants(root).filter(
118
+ (candidate) => candidate.tagName === "button" && candidate.textContent === label,
119
+ );
120
+ }
121
+
122
+ async function flush(times = 4): Promise<void> {
123
+ for (let i = 0; i < times; i += 1) {
124
+ await new Promise((resolve) => setTimeout(resolve, 0));
125
+ }
126
+ }
127
+
128
+ interface FakeClient extends RendererPetsClient {
129
+ readonly selectCalls: Array<string | null>;
130
+ }
131
+
132
+ function createFakeClient(
133
+ initialSelection: { id: string | null; displayName?: string; spriteVersionNumber?: number } = {
134
+ id: null,
135
+ },
136
+ ): FakeClient {
137
+ const selectCalls: Array<string | null> = [];
138
+ let selection = initialSelection;
139
+ const names: Record<string, string> = { rush: "Rush", bsod: "BSOD" };
140
+ return {
141
+ selectCalls,
142
+ catalog: async () => ({
143
+ officialAvailable: true,
144
+ petsDir: "/tmp/pets",
145
+ data: [
146
+ {
147
+ id: "rush",
148
+ displayName: "Rush",
149
+ description: "installed pet",
150
+ source: "installed",
151
+ installed: true,
152
+ spriteVersionNumber: 2,
153
+ },
154
+ {
155
+ id: "bsod",
156
+ displayName: "BSOD",
157
+ description: "official preload",
158
+ source: "official",
159
+ installed: false,
160
+ },
161
+ ],
162
+ }),
163
+ community: async () => ({ data: [], total: 0, source: "online" }),
164
+ preview: async (id: string) => ({ id, mime: "image/webp", dataBase64: "QUJD" }),
165
+ install: async (params: { id: string }) => ({
166
+ id: params.id,
167
+ path: `/tmp/pets/${params.id}`,
168
+ installed: true,
169
+ }),
170
+ uninstall: async (id: string) => ({ id, removed: true }),
171
+ selection: async () => selection,
172
+ select: async (id: string | null) => {
173
+ selectCalls.push(id);
174
+ selection = id === null ? { id: null } : { id, displayName: names[id] ?? id, spriteVersionNumber: 2 };
175
+ return selection;
176
+ },
177
+ };
178
+ }
179
+
180
+ function mountPetPage(client: RendererPetsClient) {
181
+ const document = new FakeDocument();
182
+ const content = document.createElement("div");
183
+ document.contentRoot = content;
184
+ const page = createPetSettingsPage(rendererSettingsMessages("en"), () => client);
185
+ const dispose = page.mount({
186
+ content: content as unknown as HTMLElement,
187
+ signal: new AbortController().signal,
188
+ runLatest: async (operation, handlers) => {
189
+ try {
190
+ handlers.success(await operation(new AbortController().signal));
191
+ } catch (error) {
192
+ handlers.failure(error);
193
+ }
194
+ },
195
+ });
196
+ return { document, content, dispose };
197
+ }
198
+
199
+ describe("Pet market page official-pet switch flow", () => {
200
+ it("switches the official pet from a card and mirrors it in the panel immediately", async () => {
201
+ vi.stubGlobal("window", {
202
+ setInterval,
203
+ clearInterval,
204
+ setTimeout,
205
+ clearTimeout,
206
+ });
207
+ switchCalls.length = 0;
208
+ switchBehavior = async () => {};
209
+ try {
210
+ const client = createFakeClient();
211
+ const { content, dispose } = mountPetPage(client);
212
+ await flush();
213
+
214
+ // 初始:空态提示,无选中
215
+ const panel = elementsWithClass(content, "pet-current")[0]!;
216
+ expect(panel.dataset.active).toBe("false");
217
+ expect(
218
+ descendants(panel).some((el) => el.textContent.includes("No pet selected yet")),
219
+ ).toBe(true);
220
+
221
+ // 已安装的 rush 卡片上有 Use 按钮,未安装的 bsod 卡片上是 Install
222
+ const rushCard = descendants(content).find((el) => el.dataset.petId === "rush")!;
223
+ const bsodCard = descendants(content).find((el) => el.dataset.petId === "bsod")!;
224
+ expect(buttonsWithLabel(rushCard, "Use")).toHaveLength(1);
225
+ expect(buttonsWithLabel(bsodCard, "Use")).toHaveLength(0);
226
+ expect(buttonsWithLabel(bsodCard, "Install Pet")).toHaveLength(1);
227
+
228
+ // 点击 Use:先驱动官方设置切换,确认后写本地记录,面板立即更新并出现 Active 徽标
229
+ buttonsWithLabel(rushCard, "Use")[0]!.dispatch("click");
230
+ await flush();
231
+ expect(switchCalls).toEqual([{ id: "rush", displayName: "Rush" }]);
232
+ expect(client.selectCalls).toEqual(["rush"]);
233
+ expect(panel.dataset.active).toBe("true");
234
+ expect(descendants(panel).some((el) => el.textContent === "Rush")).toBe(true);
235
+ // 面板注明它镜像的是 Codex 官方桌宠
236
+ expect(
237
+ descendants(panel).some((el) => el.textContent.includes("official pet")),
238
+ ).toBe(true);
239
+
240
+ const rushCardAfter = descendants(content).find((el) => el.dataset.petId === "rush")!;
241
+ expect(
242
+ elementsWithClass(rushCardAfter, "pet-card__badge--active").some(
243
+ (el) => el.textContent === "Active",
244
+ ),
245
+ ).toBe(true);
246
+ expect(buttonsWithLabel(rushCardAfter, "Use")).toHaveLength(0);
247
+
248
+ // 清除本地记录(不影响官方 mascot 显隐,文案指向官方开关)
249
+ buttonsWithLabel(panel, "Clear")[0]!.dispatch("click");
250
+ await flush();
251
+ expect(client.selectCalls).toEqual(["rush", null]);
252
+ expect(panel.dataset.active).toBe("false");
253
+ dispose?.();
254
+ } finally {
255
+ vi.unstubAllGlobals();
256
+ }
257
+ });
258
+
259
+ it("shows a switch failure in the card when the official UI automation fails and allows retry", async () => {
260
+ vi.stubGlobal("window", {
261
+ setInterval,
262
+ clearInterval,
263
+ setTimeout,
264
+ clearTimeout,
265
+ });
266
+ switchCalls.length = 0;
267
+ let failNext = true;
268
+ switchBehavior = async () => {
269
+ if (failNext) {
270
+ failNext = false;
271
+ throw new Error("Pets entry not found in the Codex command menu");
272
+ }
273
+ };
274
+ try {
275
+ const client = createFakeClient();
276
+ const { content, dispose } = mountPetPage(client);
277
+ await flush();
278
+
279
+ const rushCard = descendants(content).find((el) => el.dataset.petId === "rush")!;
280
+ buttonsWithLabel(rushCard, "Use")[0]!.dispatch("click");
281
+ await flush();
282
+
283
+ // 官方 UI 自动化失败:本地记录不落盘,错误显示在卡片内
284
+ const failedCard = descendants(content).find((el) => el.dataset.petId === "rush")!;
285
+ expect(client.selectCalls).toEqual([]);
286
+ expect(failedCard.dataset.state).toBe("failed");
287
+ expect(
288
+ descendants(failedCard).some((el) =>
289
+ el.textContent.includes("Pets entry not found in the Codex command menu"),
290
+ ),
291
+ ).toBe(true);
292
+ expect(descendants(failedCard).some((el) => el.textContent === "Switch failed")).toBe(true);
293
+
294
+ // 重试成功后面板切换
295
+ buttonsWithLabel(failedCard, "Retry")[0]!.dispatch("click");
296
+ await flush();
297
+ const panel = elementsWithClass(content, "pet-current")[0]!;
298
+ expect(panel.dataset.active).toBe("true");
299
+ expect(descendants(panel).some((el) => el.textContent === "Rush")).toBe(true);
300
+ dispose?.();
301
+ } finally {
302
+ vi.unstubAllGlobals();
303
+ }
304
+ });
305
+
306
+ it("clears the panel when the active pet is uninstalled", async () => {
307
+ vi.stubGlobal("window", {
308
+ setInterval,
309
+ clearInterval,
310
+ setTimeout,
311
+ clearTimeout,
312
+ });
313
+ switchCalls.length = 0;
314
+ switchBehavior = async () => {};
315
+ try {
316
+ const client = createFakeClient();
317
+ const { content, dispose } = mountPetPage(client);
318
+ await flush();
319
+
320
+ // 先选中 rush
321
+ const rushCard = descendants(content).find((el) => el.dataset.petId === "rush")!;
322
+ buttonsWithLabel(rushCard, "Use")[0]!.dispatch("click");
323
+ await flush();
324
+ expect(client.selectCalls).toEqual(["rush"]);
325
+ const panel = elementsWithClass(content, "pet-current")[0]!;
326
+ expect(panel.dataset.active).toBe("true");
327
+
328
+ // 卸载当前选中的 rush:面板立即清空(本地记录被清除),卡片回到未安装态
329
+ const selectedCard = descendants(content).find((el) => el.dataset.petId === "rush")!;
330
+ buttonsWithLabel(selectedCard, "Uninstall")[0]!.dispatch("click");
331
+ await flush();
332
+ expect(panel.dataset.active).toBe("false");
333
+ expect(
334
+ descendants(panel).some((el) => el.textContent.includes("No pet selected yet")),
335
+ ).toBe(true);
336
+ const uninstalledCard = descendants(content).find((el) => el.dataset.petId === "rush")!;
337
+ expect(uninstalledCard.dataset.installed).toBe("false");
338
+ expect(elementsWithClass(uninstalledCard, "pet-card__badge--active")).toHaveLength(0);
339
+ expect(buttonsWithLabel(uninstalledCard, "Use")).toHaveLength(0);
340
+ dispose?.();
341
+ } finally {
342
+ vi.unstubAllGlobals();
343
+ }
344
+ });
345
+
346
+ it("shows the Active badge on first paint when a selection already exists", async () => {
347
+ vi.stubGlobal("window", {
348
+ setInterval,
349
+ clearInterval,
350
+ setTimeout,
351
+ clearTimeout,
352
+ });
353
+ switchCalls.length = 0;
354
+ switchBehavior = async () => {};
355
+ try {
356
+ // catalog() 先于 selection() 返回时,卡片首次渲染必须随选择到位重渲染
357
+ const client = createFakeClient({ id: "rush", displayName: "Rush", spriteVersionNumber: 2 });
358
+ const { content, dispose } = mountPetPage(client);
359
+ await flush();
360
+
361
+ const panel = elementsWithClass(content, "pet-current")[0]!;
362
+ expect(panel.dataset.active).toBe("true");
363
+ expect(descendants(panel).some((el) => el.textContent === "Rush")).toBe(true);
364
+
365
+ const rushCard = descendants(content).find((el) => el.dataset.petId === "rush")!;
366
+ expect(
367
+ elementsWithClass(rushCard, "pet-card__badge--active").some(
368
+ (el) => el.textContent === "Active",
369
+ ),
370
+ ).toBe(true);
371
+ expect(buttonsWithLabel(rushCard, "Use")).toHaveLength(0);
372
+ expect(buttonsWithLabel(rushCard, "Uninstall")).toHaveLength(1);
373
+ dispose?.();
374
+ } finally {
375
+ vi.unstubAllGlobals();
376
+ }
377
+ });
378
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createRendererPetsClient } from "../../src/settings/pets-client.js";
4
+
5
+ interface RecordedCall {
6
+ readonly method: string;
7
+ readonly params: unknown;
8
+ }
9
+
10
+ function recordingClient(response: unknown) {
11
+ const calls: RecordedCall[] = [];
12
+ const client = createRendererPetsClient(async (method, params) => {
13
+ calls.push({ method, params });
14
+ return response;
15
+ });
16
+ return { calls, client };
17
+ }
18
+
19
+ describe("RendererPetsClient", () => {
20
+ it("requests the catalog", async () => {
21
+ const { calls, client } = recordingClient({ data: [], officialAvailable: false, petsDir: "/tmp/pets" });
22
+ const result = await client.catalog();
23
+ expect(calls).toEqual([{ method: "harnessmix/pets/catalog", params: {} }]);
24
+ expect(result.petsDir).toBe("/tmp/pets");
25
+ });
26
+
27
+ it("passes community query params through", async () => {
28
+ const { calls, client } = recordingClient({ data: [], total: 0, source: "online" });
29
+ await client.community({ page: 2, pageSize: 12, sort: "new", search: "cat" });
30
+ expect(calls).toEqual([
31
+ {
32
+ method: "harnessmix/pets/community",
33
+ params: { page: 2, pageSize: 12, sort: "new", search: "cat" },
34
+ },
35
+ ]);
36
+ });
37
+
38
+ it("loads a preview by id", async () => {
39
+ const { calls, client } = recordingClient({ id: "rush", mime: "image/webp", dataBase64: "AA" });
40
+ const result = await client.preview("rush");
41
+ expect(calls).toEqual([{ method: "harnessmix/pets/preview", params: { id: "rush" } }]);
42
+ expect(result.dataBase64).toBe("AA");
43
+ });
44
+
45
+ it("sends install params unchanged", async () => {
46
+ const { calls, client } = recordingClient({ id: "rush", path: "/tmp/pets/rush", installed: true });
47
+ await client.install({ id: "rush", displayName: "Rush", spriteVersionNumber: 2 });
48
+ expect(calls).toEqual([
49
+ {
50
+ method: "harnessmix/pets/install",
51
+ params: { id: "rush", displayName: "Rush", spriteVersionNumber: 2 },
52
+ },
53
+ ]);
54
+ });
55
+
56
+ it("uninstalls by id", async () => {
57
+ const { calls, client } = recordingClient({ id: "rush", removed: true });
58
+ const result = await client.uninstall("rush");
59
+ expect(calls).toEqual([{ method: "harnessmix/pets/uninstall", params: { id: "rush" } }]);
60
+ expect(result.removed).toBe(true);
61
+ });
62
+
63
+ it("requests the current selection", async () => {
64
+ const { calls, client } = recordingClient({ id: "rush", displayName: "Rush", spriteVersionNumber: 2 });
65
+ const result = await client.selection();
66
+ expect(calls).toEqual([{ method: "harnessmix/pets/selection", params: {} }]);
67
+ expect(result).toEqual({ id: "rush", displayName: "Rush", spriteVersionNumber: 2 });
68
+ });
69
+
70
+ it("selects a pet by id", async () => {
71
+ const { calls, client } = recordingClient({ id: "rush", displayName: "Rush" });
72
+ const result = await client.select("rush");
73
+ expect(calls).toEqual([{ method: "harnessmix/pets/select", params: { id: "rush" } }]);
74
+ expect(result.id).toBe("rush");
75
+ });
76
+
77
+ it("clears the selection with null", async () => {
78
+ const { calls, client } = recordingClient({ id: null });
79
+ const result = await client.select(null);
80
+ expect(calls).toEqual([{ method: "harnessmix/pets/select", params: { id: null } }]);
81
+ expect(result.id).toBeNull();
82
+ });
83
+ });
@@ -22,6 +22,7 @@ describe("Renderer settings foundation", () => {
22
22
  expect(pages.map(({ id }) => id)).toEqual(DEFAULT_RENDERER_SETTINGS_PAGE_IDS);
23
23
  expect(pages.map(({ label }) => label)).toEqual([
24
24
  "Connections",
25
+ "Collaboration",
25
26
  "Accounts",
26
27
  "MCP",
27
28
  "Skills",
@@ -34,6 +35,7 @@ describe("Renderer settings foundation", () => {
34
35
  ]);
35
36
  expect(pages.map(({ icon }) => icon)).toEqual([
36
37
  "connections",
38
+ "collaboration",
37
39
  "accounts",
38
40
  "connections",
39
41
  "session-import",
@@ -72,6 +74,7 @@ describe("Renderer settings foundation", () => {
72
74
 
73
75
  expect(pages.map(({ id }) => id)).toEqual([
74
76
  "connections",
77
+ "collaboration",
75
78
  "accounts",
76
79
  "mcp",
77
80
  "skills",
@@ -79,10 +79,24 @@ describe("renderer skin runtime", () => {
79
79
  "styler-merry-big-top",
80
80
  "styler-nocturne-studio",
81
81
  "styler-quiet-garden",
82
+ "dream-gothic-void-crusade",
83
+ "palette-catppuccin-latte",
84
+ "palette-catppuccin-mocha",
85
+ "palette-claude-desktop-dark",
86
+ "palette-claude-desktop-light",
87
+ "palette-gruvbox-dark",
88
+ "palette-gruvbox-light",
89
+ "palette-nord-dark",
90
+ "palette-nord-light",
91
+ "palette-one-dark",
92
+ "palette-one-light",
93
+ "palette-tokyo-night-dark",
94
+ "palette-tokyo-night-light",
82
95
  ]);
83
96
  expect(RENDERER_SKINS[0]?.sourceUrl).toBeNull();
84
97
  expect(RENDERER_SKINS.slice(1).every(({ sourceUrl }) => sourceUrl?.startsWith("https://github.com/"))).toBe(true);
85
- expect(RENDERER_SKINS.slice(1).every(({ heroUrl }) => Boolean(heroUrl))).toBe(true);
98
+ expect(RENDERER_SKINS.slice(1).every((skin) => Boolean(skin.heroUrl) || skin.id.startsWith("palette-"))).toBe(true);
99
+ expect(RENDERER_SKINS.filter((skin) => skin.id.startsWith("palette-")).every((skin) => skin.preview.startsWith("linear-gradient"))).toBe(true);
86
100
  const miku = RENDERER_SKINS.find(({ id }) => id === "miku-488137");
87
101
  expect(miku?.logoUrl).toContain("logo.webp");
88
102
  expect(miku?.polaroidUrl).toContain("polaroid.webp");