@harness-mix/cli 0.2.0 → 0.2.1

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 +13 -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 +1420 -1341
  30. package/src/main/native/pets.js +79 -2
  31. package/src/main/native/protocol.js +1160 -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
@@ -16,6 +16,7 @@ import gildedGrandeurHero from "../../../../assets/skins/codex-styler/themes/gil
16
16
  import merryBigTopHero from "../../../../assets/skins/codex-styler/themes/merry-big-top/hero.webp";
17
17
  import nocturneStudioHero from "../../../../assets/skins/codex-styler/themes/nocturne-studio/hero.webp";
18
18
  import quietGardenHero from "../../../../assets/skins/codex-styler/themes/quiet-garden/hero.webp";
19
+ import gothicVoidCrusadeHero from "../../../../assets/skins/dream-skin/themes/gothic-void-crusade/hero.webp";
19
20
 
20
21
  export const RENDERER_SKIN_STORAGE_KEY = "harness-mix.renderer-skin.v1";
21
22
  export const RENDERER_SKIN_STYLE_ID = "harness-mix-renderer-skin";
@@ -26,7 +27,11 @@ export const RENDERER_SKIN_IDS = [
26
27
  "wuthering-tide", "naruto-hokage", "naruto-sasuke", "deepspace-dawn",
27
28
  "deepspace-star", "dragonball-nimbus", "dragonball-super-saiyan", "dalao-dianyan",
28
29
  "styler-gilded-grandeur", "styler-merry-big-top", "styler-nocturne-studio",
29
- "styler-quiet-garden",
30
+ "styler-quiet-garden", "dream-gothic-void-crusade",
31
+ "palette-catppuccin-latte", "palette-catppuccin-mocha", "palette-claude-desktop-dark",
32
+ "palette-claude-desktop-light", "palette-gruvbox-dark", "palette-gruvbox-light",
33
+ "palette-nord-dark", "palette-nord-light", "palette-one-dark", "palette-one-light",
34
+ "palette-tokyo-night-dark", "palette-tokyo-night-light",
30
35
  ] as const;
31
36
 
32
37
  export type RendererSkinId = (typeof RENDERER_SKIN_IDS)[number];
@@ -47,6 +52,8 @@ export interface RendererSkinDefinition {
47
52
 
48
53
  const HEIGE_SOURCE_URL = "https://github.com/HeiGeAi/heige-codex-skin-studio";
49
54
  const CODEX_STYLER_SOURCE_URL = "https://github.com/xuhuanstudio/codex-styler";
55
+ const DREAM_SKIN_SOURCE_URL = "https://github.com/Fei-Away/Codex-Dream-Skin";
56
+ const ANTHROPIC_THEME_SOURCE_URL = "https://github.com/miniLV/Anthropic-codex-theme";
50
57
 
51
58
  function heigeSkin(
52
59
  id: Exclude<RendererSkinId, "native">,
@@ -83,6 +90,36 @@ function codexStylerSkin(
83
90
  });
84
91
  }
85
92
 
93
+ function dreamSkin(
94
+ id: Exclude<RendererSkinId, "native">,
95
+ name: string,
96
+ heroUrl: string,
97
+ palette: RendererSkinDefinition["palette"],
98
+ focus: string,
99
+ ): RendererSkinDefinition {
100
+ return Object.freeze({
101
+ id, name, heroUrl, logoUrl: null, polaroidUrl: null, palette, focus, dark: true,
102
+ sourceName: "Codex Dream Skin · MIT",
103
+ sourceUrl: DREAM_SKIN_SOURCE_URL,
104
+ preview: "linear-gradient(rgb(3 7 12 / 16%), transparent)",
105
+ });
106
+ }
107
+
108
+ function paletteSkin(
109
+ id: Exclude<RendererSkinId, "native">,
110
+ name: string,
111
+ palette: RendererSkinDefinition["palette"],
112
+ panel: string,
113
+ dark: boolean,
114
+ ): RendererSkinDefinition {
115
+ return Object.freeze({
116
+ id, name, heroUrl: null, logoUrl: null, polaroidUrl: null, palette, focus: "50% 50%", dark,
117
+ sourceName: "Anthropic Codex Theme · MIT",
118
+ sourceUrl: ANTHROPIC_THEME_SOURCE_URL,
119
+ preview: `linear-gradient(155deg, ${palette[0]} 0 58%, ${panel} 58% 100%)`,
120
+ });
121
+ }
122
+
86
123
  export const RENDERER_SKINS: readonly RendererSkinDefinition[] = Object.freeze([
87
124
  Object.freeze({
88
125
  id: "native", name: "Native Codex", sourceName: "OpenAI Codex", sourceUrl: null,
@@ -115,6 +152,19 @@ export const RENDERER_SKINS: readonly RendererSkinDefinition[] = Object.freeze([
115
152
  codexStylerSkin("styler-merry-big-top", "欢乐大帐篷", merryBigTopHero, ["#100b14", "#5a3d4b", "#ff755e", "#fff2dc"], "55% 48%"),
116
153
  codexStylerSkin("styler-nocturne-studio", "夜曲工作室", nocturneStudioHero, ["#090b0d", "#34383a", "#e9a066", "#f5efe6"], "58% 46%"),
117
154
  codexStylerSkin("styler-quiet-garden", "静谧花园", quietGardenHero, ["#101612", "#364339", "#9fc29a", "#edf2e8"], "52% 50%"),
155
+ dreamSkin("dream-gothic-void-crusade", "哥特虚空远征", gothicVoidCrusadeHero, ["#0d0d0e", "#b5a386", "#c8a55a", "#f3ead7"], "76% 45%"),
156
+ paletteSkin("palette-catppuccin-latte", "Catppuccin · 拿铁", ["#EFF1F5", "#5C5F77", "#FE640B", "#4C4F69"], "#E6E9EF", false),
157
+ paletteSkin("palette-catppuccin-mocha", "Catppuccin · 摩卡", ["#181825", "#A6ADC8", "#FAB387", "#CDD6F4"], "#1E1E2E", true),
158
+ paletteSkin("palette-claude-desktop-dark", "Claude · 墨夜", ["#1A1918", "#A8A49C", "#CA7554", "#E8E4DC"], "#222120", true),
159
+ paletteSkin("palette-claude-desktop-light", "Claude · 素纸", ["#F8F6F1", "#605B54", "#CA7554", "#38342E"], "#F3EFE7", false),
160
+ paletteSkin("palette-gruvbox-dark", "Gruvbox · 暖夜", ["#282828", "#D5C4A1", "#FE8019", "#EBDBB2"], "#32302F", true),
161
+ paletteSkin("palette-gruvbox-light", "Gruvbox · 暖昼", ["#FBF1C7", "#504945", "#D65D0E", "#3C3836"], "#F2E5BC", false),
162
+ paletteSkin("palette-nord-dark", "Nord · 极夜", ["#2E3440", "#C3CBD9", "#88C0D0", "#D8DEE9"], "#3B4252", true),
163
+ paletteSkin("palette-nord-light", "Nord · 雪原", ["#ECEFF4", "#3B4252", "#5E81AC", "#2E3440"], "#E5E9F0", false),
164
+ paletteSkin("palette-one-dark", "One Dark · 子夜", ["#282C34", "#9DA5B4", "#61AFEF", "#ABB2BF"], "#21252B", true),
165
+ paletteSkin("palette-one-light", "One Light · 皓昼", ["#FAFAFA", "#4F525E", "#4078F2", "#383A42"], "#F0F0F1", false),
166
+ paletteSkin("palette-tokyo-night-dark", "Tokyo Night · 夜", ["#1A1B26", "#A9B1D6", "#7AA2F7", "#C0CAF5"], "#16161E", true),
167
+ paletteSkin("palette-tokyo-night-light", "Tokyo Night · 晨", ["#E1E2E7", "#4C505E", "#2E7DE9", "#343B59"], "#D5D6DB", false),
118
168
  ]);
119
169
 
120
170
  interface RendererSkinStorage {
@@ -292,6 +292,7 @@ describe("Renderer fixed Model request client", () => {
292
292
  "deleteCodexAccount",
293
293
  "executeThreadCommand",
294
294
  "forkThread",
295
+ "getCollaborationPreferences",
295
296
  "importHarnessSession",
296
297
  "inspectCodexAccountUsage",
297
298
  "inspectHarness",
@@ -321,6 +322,7 @@ describe("Renderer fixed Model request client", () => {
321
322
  "readUpdateStatus",
322
323
  "refreshCodexAccounts",
323
324
  "removeMcp",
325
+ "saveCollaborationPreferences",
324
326
  "saveMcp",
325
327
  "selectThreadModel",
326
328
  "selectThreadPermissionMode",
@@ -0,0 +1,400 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { switchOfficialCodexPet } from "../src/renderer-pet-switcher.js";
3
+
4
+ const FAST_TIMING = {
5
+ pollMs: 10,
6
+ openTimeoutMs: 400,
7
+ gridTimeoutMs: 400,
8
+ confirmTimeoutMs: 250,
9
+ closeTimeoutMs: 300,
10
+ menuSettleMs: 40,
11
+ } as const;
12
+
13
+ /** Minimal DOM node covering exactly what the switcher touches. */
14
+ class FakeElement {
15
+ readonly attributes = new Map<string, string>();
16
+ readonly children: FakeElement[] = [];
17
+ readonly listeners = new Map<string, Array<(event: Event) => void>>();
18
+ parent: FakeElement | null = null;
19
+ text = "";
20
+ rect = { x: 0, y: 0, width: 12, height: 12, top: 0, left: 0, right: 12, bottom: 12 };
21
+
22
+ constructor(readonly tagName: string) {}
23
+
24
+ get ownerDocument(): FakeDocument | null {
25
+ let node: FakeElement | null = this;
26
+ while (node) {
27
+ if (node instanceof FakeDocument) return node;
28
+ node = node.parent;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ get textContent(): string {
34
+ return this.text + this.children.map((child) => child.textContent).join("");
35
+ }
36
+
37
+ getBoundingClientRect(): FakeElement["rect"] {
38
+ return this.rect;
39
+ }
40
+
41
+ append(...children: FakeElement[]): void {
42
+ for (const child of children) {
43
+ child.parent = this;
44
+ this.children.push(child);
45
+ }
46
+ }
47
+
48
+ remove(): void {
49
+ this.parent?.removeChild(this);
50
+ }
51
+
52
+ removeChild(child: FakeElement): void {
53
+ const index = this.children.indexOf(child);
54
+ if (index !== -1) this.children.splice(index, 1);
55
+ child.parent = null;
56
+ }
57
+
58
+ setAttribute(name: string, value: string): void {
59
+ this.attributes.set(name, value);
60
+ }
61
+
62
+ getAttribute(name: string): string | null {
63
+ return this.attributes.get(name) ?? null;
64
+ }
65
+
66
+ hasAttribute(name: string): boolean {
67
+ return this.attributes.has(name);
68
+ }
69
+
70
+ addEventListener(type: string, listener: (event: Event) => void): void {
71
+ const list = this.listeners.get(type) ?? [];
72
+ list.push(listener);
73
+ this.listeners.set(type, list);
74
+ }
75
+
76
+ dispatchEvent(event: Event): boolean {
77
+ for (const listener of this.listeners.get(event.type) ?? []) listener(event);
78
+ if (event.bubbles && this.parent) this.parent.dispatchEvent(event);
79
+ return true;
80
+ }
81
+
82
+ click(): void {
83
+ this.dispatchEvent(new Event("click", { bubbles: true }));
84
+ }
85
+
86
+ closest(selector: string): FakeElement | null {
87
+ let node: FakeElement | null = this;
88
+ while (node) {
89
+ if (matchesSelector(node, selector)) return node;
90
+ node = node.parent;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ querySelector(selector: string): FakeElement | null {
96
+ return this.querySelectorAll(selector)[0] ?? null;
97
+ }
98
+
99
+ querySelectorAll(selector: string): FakeElement[] {
100
+ const out: FakeElement[] = [];
101
+ const walk = (node: FakeElement): void => {
102
+ for (const child of node.children) {
103
+ if (matchesSelector(child, selector)) out.push(child);
104
+ walk(child);
105
+ }
106
+ };
107
+ walk(this);
108
+ return out;
109
+ }
110
+ }
111
+
112
+ function matchesSelector(element: FakeElement, selector: string): boolean {
113
+ return selector.split(",").some((part) => {
114
+ const trimmed = part.trim();
115
+ const attrMatch = /^(?:([a-z]+))?\[([a-zA-Z-]+)(?:="([^"]*)")?\]$/.exec(trimmed);
116
+ if (attrMatch) {
117
+ const [, tag, attr, value] = attrMatch;
118
+ if (tag && element.tagName !== tag) return false;
119
+ if (!element.hasAttribute(attr!)) return false;
120
+ if (value !== undefined && element.getAttribute(attr!) !== value) return false;
121
+ return true;
122
+ }
123
+ return element.tagName === trimmed;
124
+ });
125
+ }
126
+
127
+ class FakeDocument extends FakeElement {
128
+ readonly defaultView = { navigator: { platform: "Win32" }, PointerEvent: undefined };
129
+
130
+ constructor() {
131
+ super("#document");
132
+ }
133
+ }
134
+
135
+ let fakeDocument: FakeDocument;
136
+
137
+ interface FakeAppOptions {
138
+ /** Pet avatar ids in the official grid (already mapped form, e.g. custom:rush / bsod). */
139
+ readonly petIds: readonly string[];
140
+ /** Whether clicking a pet card confirms the selection (the app's account mutation). */
141
+ readonly confirmOnClick?: boolean;
142
+ /** Avatar id that starts out selected. */
143
+ readonly initiallySelected?: string;
144
+ /** Whether the profile menu exists at all. */
145
+ readonly withProfileMenu?: boolean;
146
+ }
147
+
148
+ /**
149
+ * Simulates the official Codex app's reactions to the switcher's genuine DOM
150
+ * events, mirroring the live-verified structure: profile menu (Radix) with a
151
+ * "…Ctrl+," settings item → settings surface with data-settings-panel-slug
152
+ * nav + main>header back button → pet grid of button[aria-pressed] cards.
153
+ */
154
+ function installFakeOfficialApp(options: FakeAppOptions) {
155
+ const state = {
156
+ profileMenuOpen: false,
157
+ settingsOpen: false,
158
+ settingsSection: null as string | null,
159
+ selectedPetId: options.initiallySelected ?? null,
160
+ profileClicks: 0,
161
+ clickedPetIds: [] as string[],
162
+ backClicks: 0,
163
+ };
164
+
165
+ const menuRoot = new FakeElement("div");
166
+ const settingsRoot = new FakeElement("div");
167
+
168
+ const openProfileMenu = () => {
169
+ state.profileMenuOpen = true;
170
+ const menu = new FakeElement("div");
171
+ const settingsItem = new FakeElement("div");
172
+ settingsItem.setAttribute("role", "menuitem");
173
+ settingsItem.text = "设置Ctrl+,";
174
+ settingsItem.addEventListener("click", () => {
175
+ closeProfileMenu();
176
+ openSettings("general-settings");
177
+ });
178
+ const usageItem = new FakeElement("div");
179
+ usageItem.setAttribute("role", "menuitem");
180
+ usageItem.text = "剩余用量";
181
+ menu.append(settingsItem, usageItem);
182
+ menuRoot.append(menu);
183
+ };
184
+ const closeProfileMenu = () => {
185
+ state.profileMenuOpen = false;
186
+ menuRoot.children.splice(0);
187
+ };
188
+
189
+ const openSettings = (section: string) => {
190
+ state.settingsOpen = true;
191
+ state.settingsSection = section;
192
+ renderSettings();
193
+ };
194
+ const closeSettings = () => {
195
+ state.settingsOpen = false;
196
+ state.settingsSection = null;
197
+ settingsRoot.children.splice(0);
198
+ };
199
+
200
+ const renderSettings = () => {
201
+ settingsRoot.children.splice(0);
202
+ const main = new FakeElement("main");
203
+ const header = new FakeElement("header");
204
+ const back = new FakeElement("button");
205
+ back.setAttribute("aria-label", "返回 ChatGPT");
206
+ back.addEventListener("click", () => {
207
+ state.backClicks += 1;
208
+ closeSettings();
209
+ });
210
+ header.append(back);
211
+ main.append(header);
212
+ const nav = new FakeElement("nav");
213
+ for (const slug of ["general-settings", "pets"]) {
214
+ const item = new FakeElement("button");
215
+ item.setAttribute("data-settings-panel-slug", slug);
216
+ item.text = slug;
217
+ item.addEventListener("click", () => openSettings(slug));
218
+ nav.append(item);
219
+ }
220
+ main.append(nav);
221
+ if (state.settingsSection === "pets") {
222
+ const grid = new FakeElement("div");
223
+ for (const petId of options.petIds) {
224
+ const card = new FakeElement("button");
225
+ card.setAttribute("aria-pressed", String(state.selectedPetId === petId));
226
+ card.text = petId;
227
+ const artwork = new FakeElement("div");
228
+ artwork.setAttribute("data-avatar-id", petId);
229
+ card.append(artwork);
230
+ card.addEventListener("click", () => {
231
+ state.clickedPetIds.push(petId);
232
+ if (options.confirmOnClick !== false) {
233
+ state.selectedPetId = petId;
234
+ renderSettings(); // the app re-renders after the account mutation
235
+ }
236
+ });
237
+ grid.append(card);
238
+ }
239
+ main.append(grid);
240
+ }
241
+ settingsRoot.append(main);
242
+ };
243
+
244
+ if (options.withProfileMenu !== false) {
245
+ const profileButton = new FakeElement("button");
246
+ profileButton.setAttribute("aria-haspopup", "menu");
247
+ profileButton.rect = { x: 8, y: 680, width: 40, height: 40, top: 680, left: 8, right: 48, bottom: 720 };
248
+ const avatarImg = new FakeElement("img");
249
+ profileButton.append(avatarImg);
250
+ profileButton.addEventListener("click", () => {
251
+ state.profileClicks += 1;
252
+ if (state.profileMenuOpen) closeProfileMenu();
253
+ else openProfileMenu();
254
+ });
255
+ // 另一个带 img 的菜单按钮(模型选择器),停靠在右下:必须不被误选
256
+ const modelButton = new FakeElement("button");
257
+ modelButton.setAttribute("aria-haspopup", "menu");
258
+ modelButton.rect = { x: 748, y: 666, width: 90, height: 32, top: 666, left: 748, right: 838, bottom: 698 };
259
+ modelButton.append(new FakeElement("img"));
260
+ fakeDocument.append(profileButton, modelButton);
261
+ }
262
+
263
+ fakeDocument.append(menuRoot, settingsRoot);
264
+ return state;
265
+ }
266
+
267
+ const runSwitch = (target: { id: string; displayName: string }) =>
268
+ switchOfficialCodexPet(target, {
269
+ ownerDocument: fakeDocument as unknown as Document,
270
+ timing: FAST_TIMING,
271
+ });
272
+
273
+ describe("switchOfficialCodexPet", () => {
274
+ it("drives the official UI: profile menu → Settings item → pets nav → pet card → confirmed → settings closed", async () => {
275
+ fakeDocument = new FakeDocument();
276
+ const app = installFakeOfficialApp({ petIds: ["custom:rush", "bsod"] });
277
+
278
+ const result = await runSwitch({ id: "rush", displayName: "Rush" });
279
+
280
+ expect(result).toEqual({ avatarId: "custom:rush", alreadySelected: false });
281
+ expect(app.profileClicks).toBe(1); // opened the profile menu once
282
+ expect(app.clickedPetIds).toEqual(["custom:rush"]); // genuine click on the official card
283
+ expect(app.selectedPetId).toBe("custom:rush"); // confirmed by aria-pressed
284
+ expect(app.settingsOpen).toBe(false); // settings left closed afterwards
285
+ expect(app.backClicks).toBe(1);
286
+ });
287
+
288
+ it("maps built-in pets to their bare avatar id", async () => {
289
+ fakeDocument = new FakeDocument();
290
+ const app = installFakeOfficialApp({ petIds: ["bsod", "codex"] });
291
+ const result = await runSwitch({ id: "bsod", displayName: "BSOD" });
292
+ expect(result).toEqual({ avatarId: "bsod", alreadySelected: false });
293
+ expect(app.selectedPetId).toBe("bsod");
294
+ });
295
+
296
+ it("picks the left-most avatar menu button (not the model picker)", async () => {
297
+ fakeDocument = new FakeDocument();
298
+ const app = installFakeOfficialApp({ petIds: ["custom:rush"] });
299
+ await runSwitch({ id: "rush", displayName: "Rush" });
300
+ expect(app.profileClicks).toBe(1); // only the profile button was triggered
301
+ expect(app.selectedPetId).toBe("custom:rush");
302
+ });
303
+
304
+ it("does not click when the pet is already selected", async () => {
305
+ fakeDocument = new FakeDocument();
306
+ const app = installFakeOfficialApp({
307
+ petIds: ["custom:rush"],
308
+ initiallySelected: "custom:rush",
309
+ });
310
+ const result = await runSwitch({ id: "rush", displayName: "Rush" });
311
+ expect(result).toEqual({ avatarId: "custom:rush", alreadySelected: true });
312
+ expect(app.clickedPetIds).toEqual([]);
313
+ });
314
+
315
+ it("fails cleanly when the pet is missing from the official grid", async () => {
316
+ fakeDocument = new FakeDocument();
317
+ const app = installFakeOfficialApp({ petIds: ["bsod"] });
318
+ await expect(runSwitch({ id: "ghost", displayName: "Ghost" })).rejects.toThrow(
319
+ /not found in the official Codex Pets settings/,
320
+ );
321
+ expect(app.settingsOpen).toBe(false); // settings closed on failure
322
+ expect(app.profileMenuOpen).toBe(false);
323
+ });
324
+
325
+ it("times out with a clear error when the click is never confirmed", async () => {
326
+ fakeDocument = new FakeDocument();
327
+ const app = installFakeOfficialApp({ petIds: ["custom:rush"], confirmOnClick: false });
328
+ await expect(runSwitch({ id: "rush", displayName: "Rush" })).rejects.toThrow(/not confirmed/);
329
+ expect(app.settingsOpen).toBe(false);
330
+ });
331
+
332
+ it("rejects when the profile menu button is absent", async () => {
333
+ fakeDocument = new FakeDocument();
334
+ installFakeOfficialApp({ petIds: ["custom:rush"], withProfileMenu: false });
335
+ await expect(runSwitch({ id: "rush", displayName: "Rush" })).rejects.toThrow(
336
+ /profile menu button not found/,
337
+ );
338
+ });
339
+
340
+ it("recovers when the profile menu was already open (toggle-and-retry)", async () => {
341
+ fakeDocument = new FakeDocument();
342
+ const app = installFakeOfficialApp({ petIds: ["custom:rush"] });
343
+ // 手动点开菜单模拟残留状态:第一次 pointer 序列会把它关掉,重试路径再打开
344
+ const profileButton = fakeDocument.querySelector('button[aria-haspopup="menu"]') as FakeElement;
345
+ profileButton.click();
346
+ expect(app.profileMenuOpen).toBe(true);
347
+ const result = await runSwitch({ id: "rush", displayName: "Rush" });
348
+ expect(result.alreadySelected).toBe(false);
349
+ expect(app.profileClicks).toBeGreaterThan(1);
350
+ expect(app.selectedPetId).toBe("custom:rush");
351
+ });
352
+
353
+ it("guards against concurrent switch runs", async () => {
354
+ fakeDocument = new FakeDocument();
355
+ installFakeOfficialApp({ petIds: ["custom:rush"], confirmOnClick: false });
356
+ const first = runSwitch({ id: "rush", displayName: "Rush" });
357
+ await expect(runSwitch({ id: "bsod", displayName: "BSOD" })).rejects.toThrow(/already in progress/);
358
+ await expect(first).rejects.toThrow(/not confirmed/);
359
+ });
360
+
361
+ it("uses the settings sidebar when the official settings are already open and leaves them open", async () => {
362
+ fakeDocument = new FakeDocument();
363
+ const app = installFakeOfficialApp({ petIds: ["custom:rush"] });
364
+ // 用户自己开着官方设置(停在别的分组):直接走 data-settings-panel-slug 导航
365
+ const main = new FakeElement("main");
366
+ const header = new FakeElement("header");
367
+ const back = new FakeElement("button");
368
+ back.setAttribute("aria-label", "返回 ChatGPT");
369
+ header.append(back);
370
+ main.append(header);
371
+ const nav = new FakeElement("nav");
372
+ const generalItem = new FakeElement("button");
373
+ generalItem.setAttribute("data-settings-panel-slug", "general-settings");
374
+ const petsItem = new FakeElement("button");
375
+ petsItem.setAttribute("data-settings-panel-slug", "pets");
376
+ nav.append(generalItem, petsItem);
377
+ main.append(nav);
378
+ const gridHolder = new FakeElement("div");
379
+ main.append(gridHolder);
380
+ fakeDocument.append(main);
381
+ petsItem.addEventListener("click", () => {
382
+ const card = new FakeElement("button");
383
+ card.setAttribute("aria-pressed", "false");
384
+ const artwork = new FakeElement("div");
385
+ artwork.setAttribute("data-avatar-id", "custom:rush");
386
+ card.append(artwork);
387
+ card.addEventListener("click", () => {
388
+ app.clickedPetIds.push("custom:rush");
389
+ card.setAttribute("aria-pressed", "true");
390
+ });
391
+ gridHolder.append(card);
392
+ });
393
+
394
+ const result = await runSwitch({ id: "rush", displayName: "Rush" });
395
+ expect(result.alreadySelected).toBe(false);
396
+ expect(app.clickedPetIds).toEqual(["custom:rush"]);
397
+ expect(app.profileClicks).toBe(0); // 没有动用个人菜单
398
+ expect(app.backClicks).toBe(0); // 用户自己开着的设置保持原样
399
+ });
400
+ });
@@ -1,82 +1,83 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- codexLocaleOverrideForSettingsSelection,
5
- rendererSettingsLanguageSelection,
6
- rendererSettingsMessages,
7
- resolveRendererSettingsLocale,
8
- } from "../../src/settings/localization.js";
9
- import { createDefaultRendererSettingsPages } from "../../src/settings/pages.js";
10
-
11
- describe("Renderer settings localization", () => {
12
- it("negotiates English and Chinese language tags with an English fallback", () => {
13
- expect(resolveRendererSettingsLocale(["zh-CN"])).toBe("zh-CN");
14
- expect(resolveRendererSettingsLocale(["zh-TW"])).toBe("zh-CN");
15
- expect(resolveRendererSettingsLocale(["en-GB"])).toBe("en");
16
- expect(resolveRendererSettingsLocale(["fr-FR", "zh-CN"])).toBe("zh-CN");
17
- expect(resolveRendererSettingsLocale(["fr-FR"])).toBe("en");
18
- });
19
-
20
- it("maps the bounded selector to Codex locale override values", () => {
21
- expect(rendererSettingsLanguageSelection(null)).toBe("automatic");
22
- expect(rendererSettingsLanguageSelection("en-GB")).toBe("en");
23
- expect(rendererSettingsLanguageSelection("zh-TW")).toBe("zh-CN");
24
- expect(rendererSettingsLanguageSelection("ja-JP")).toBe("other");
25
- expect(codexLocaleOverrideForSettingsSelection("automatic")).toBeNull();
26
- expect(codexLocaleOverrideForSettingsSelection("en")).toBe("en-US");
27
- expect(codexLocaleOverrideForSettingsSelection("zh-CN")).toBe("zh-CN");
28
- });
29
-
30
- it("provides complete immutable English and Chinese settings catalogs", () => {
31
- const english = rendererSettingsMessages("en");
32
- const chinese = rendererSettingsMessages("zh-CN");
33
-
34
- expect(english.title).toBe("Settings");
35
- expect(chinese.title).toBe("设置");
36
- expect(chinese.openSettings).toBe("打开 Harness Mix 设置");
37
- expect(chinese.updateInstallation).toBe("安装方式");
38
- expect(chinese.updateInstallationWindowsInstaller).toBe("Windows 安装程序");
39
- expect(english.updateInstallationMacOsDmg).toBe("macOS DMG");
40
- expect(chinese.updateWaitingForExit).toBe("正在等待应用退出...");
41
- expect(chinese.updateInstallingNpm).toBe("正在通过 npm 安装...");
42
- expect(chinese.updateAndRestart).toBe("更新");
43
- expect(chinese.updateWindowsManualRequired).toContain("Windows 暂不支持自动更新");
44
- expect(chinese.updateWindowsInstallerDescription).toContain("适用于当前系统的安装包");
45
- expect(chinese.updateManualNpmDescription).toBe(
46
- "如需手动更新,请在终端运行以下命令。更新完成后,请退出 Codex 并通过 Harness Mix 重新启动。",
47
- );
48
- expect(english.updateInstalling).toBe("Installing update...");
49
- expect(english.updateDownloadFromReleases).toBe("Download from GitHub Releases");
50
- expect(chinese.updateDownloadFromReleases).toBe("前往 GitHub Releases 下载");
51
- expect(chinese.updateDownloadWindowsInstaller).toBe("下载 Windows 安装包");
52
- expect(chinese.pageLabels.about).toBe("关于");
53
- expect(chinese.pageLabels["session-import"]).toBe("会话导入");
54
- expect(chinese.sessionImportAvailabilityNote).toContain("可选 Harness 来自本地 Host");
55
- expect(chinese.sessionImportAvailabilityNote).toContain("先在原生客户端关闭该会话再导入");
56
- expect(chinese.sessionImportAvailabilityNote).toContain("避免同时写入");
57
- expect(english.sessionImportAvailabilityNote).toContain(
58
- "Available Harnesses come from the local Host",
59
- );
60
- expect(english.sessionImportAvailabilityNote).toContain(
61
- "close the session in its native client before importing",
62
- );
63
- expect(english.sessionImportAvailabilityNote).toContain("avoid concurrent writes");
64
- expect(chinese.aboutTagline).toBe("在 Codex Desktop 中运行各类原生 Harness");
65
- expect(chinese.aboutParagraphs).toHaveLength(3);
66
- expect(chinese.aboutStarCallout).toContain("请给我们一个 Star");
67
- expect(chinese.aboutRepository).toBe("开源仓库");
68
- expect(Object.keys(chinese.pageLabels)).toEqual(Object.keys(english.pageLabels));
69
- expect(Object.isFrozen(english)).toBe(true);
70
- expect(Object.isFrozen(chinese.pageLabels)).toBe(true);
71
- });
72
-
73
- it("localizes every default page descriptor", () => {
74
- expect(
75
- createDefaultRendererSettingsPages(rendererSettingsMessages("zh-CN")).map(
76
- ({ label }) => label,
77
- ),
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ codexLocaleOverrideForSettingsSelection,
5
+ rendererSettingsLanguageSelection,
6
+ rendererSettingsMessages,
7
+ resolveRendererSettingsLocale,
8
+ } from "../../src/settings/localization.js";
9
+ import { createDefaultRendererSettingsPages } from "../../src/settings/pages.js";
10
+
11
+ describe("Renderer settings localization", () => {
12
+ it("negotiates English and Chinese language tags with an English fallback", () => {
13
+ expect(resolveRendererSettingsLocale(["zh-CN"])).toBe("zh-CN");
14
+ expect(resolveRendererSettingsLocale(["zh-TW"])).toBe("zh-CN");
15
+ expect(resolveRendererSettingsLocale(["en-GB"])).toBe("en");
16
+ expect(resolveRendererSettingsLocale(["fr-FR", "zh-CN"])).toBe("zh-CN");
17
+ expect(resolveRendererSettingsLocale(["fr-FR"])).toBe("en");
18
+ });
19
+
20
+ it("maps the bounded selector to Codex locale override values", () => {
21
+ expect(rendererSettingsLanguageSelection(null)).toBe("automatic");
22
+ expect(rendererSettingsLanguageSelection("en-GB")).toBe("en");
23
+ expect(rendererSettingsLanguageSelection("zh-TW")).toBe("zh-CN");
24
+ expect(rendererSettingsLanguageSelection("ja-JP")).toBe("other");
25
+ expect(codexLocaleOverrideForSettingsSelection("automatic")).toBeNull();
26
+ expect(codexLocaleOverrideForSettingsSelection("en")).toBe("en-US");
27
+ expect(codexLocaleOverrideForSettingsSelection("zh-CN")).toBe("zh-CN");
28
+ });
29
+
30
+ it("provides complete immutable English and Chinese settings catalogs", () => {
31
+ const english = rendererSettingsMessages("en");
32
+ const chinese = rendererSettingsMessages("zh-CN");
33
+
34
+ expect(english.title).toBe("Settings");
35
+ expect(chinese.title).toBe("设置");
36
+ expect(chinese.openSettings).toBe("打开 Harness Mix 设置");
37
+ expect(chinese.updateInstallation).toBe("安装方式");
38
+ expect(chinese.updateInstallationWindowsInstaller).toBe("Windows 安装程序");
39
+ expect(english.updateInstallationMacOsDmg).toBe("macOS DMG");
40
+ expect(chinese.updateWaitingForExit).toBe("正在等待应用退出...");
41
+ expect(chinese.updateInstallingNpm).toBe("正在通过 npm 安装...");
42
+ expect(chinese.updateAndRestart).toBe("更新");
43
+ expect(chinese.updateWindowsManualRequired).toContain("Windows 暂不支持自动更新");
44
+ expect(chinese.updateWindowsInstallerDescription).toContain("适用于当前系统的安装包");
45
+ expect(chinese.updateManualNpmDescription).toBe(
46
+ "如需手动更新,请在终端运行以下命令。更新完成后,请退出 Codex 并通过 Harness Mix 重新启动。",
47
+ );
48
+ expect(english.updateInstalling).toBe("Installing update...");
49
+ expect(english.updateDownloadFromReleases).toBe("Download from GitHub Releases");
50
+ expect(chinese.updateDownloadFromReleases).toBe("前往 GitHub Releases 下载");
51
+ expect(chinese.updateDownloadWindowsInstaller).toBe("下载 Windows 安装包");
52
+ expect(chinese.pageLabels.about).toBe("关于");
53
+ expect(chinese.pageLabels["session-import"]).toBe("会话导入");
54
+ expect(chinese.sessionImportAvailabilityNote).toContain("可选 Harness 来自本地 Host");
55
+ expect(chinese.sessionImportAvailabilityNote).toContain("先在原生客户端关闭该会话再导入");
56
+ expect(chinese.sessionImportAvailabilityNote).toContain("避免同时写入");
57
+ expect(english.sessionImportAvailabilityNote).toContain(
58
+ "Available Harnesses come from the local Host",
59
+ );
60
+ expect(english.sessionImportAvailabilityNote).toContain(
61
+ "close the session in its native client before importing",
62
+ );
63
+ expect(english.sessionImportAvailabilityNote).toContain("avoid concurrent writes");
64
+ expect(chinese.aboutTagline).toBe("在 Codex Desktop 中运行各类原生 Harness");
65
+ expect(chinese.aboutParagraphs).toHaveLength(3);
66
+ expect(chinese.aboutStarCallout).toContain("请给我们一个 Star");
67
+ expect(chinese.aboutRepository).toBe("开源仓库");
68
+ expect(Object.keys(chinese.pageLabels)).toEqual(Object.keys(english.pageLabels));
69
+ expect(Object.isFrozen(english)).toBe(true);
70
+ expect(Object.isFrozen(chinese.pageLabels)).toBe(true);
71
+ });
72
+
73
+ it("localizes every default page descriptor", () => {
74
+ expect(
75
+ createDefaultRendererSettingsPages(rendererSettingsMessages("zh-CN")).map(
76
+ ({ label }) => label,
77
+ ),
78
78
  ).toEqual([
79
79
  "连接",
80
+ "协作",
80
81
  "账号",
81
82
  "MCP",
82
83
  "Skills",
@@ -87,5 +88,5 @@ describe("Renderer settings localization", () => {
87
88
  "更新",
88
89
  "关于",
89
90
  ]);
90
- });
91
- });
91
+ });
92
+ });