Skip to content

Commit 8efea18

Browse files
fix(desktop): preserve locale migration behavior
Generated-by: OpenCode
1 parent 8435364 commit 8efea18

12 files changed

Lines changed: 437 additions & 34 deletions

apps/desktop/renderer-architecture.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3391,6 +3391,7 @@
33913391
"dependencyPaths": {
33923392
"../features/runtime-host-management": 1,
33933393
"../features/session-collaboration": 1,
3394+
"../locales/peer-mesh-copy.js": 1,
33943395
"../locales/session-collaboration-copy.js": 1,
33953396
"../locales/settings-projects-copy.js": 1,
33963397
"./password-input.js": 1,
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import assert from 'node:assert/strict';
21+
import { mkdtemp, rm } from 'node:fs/promises';
22+
import { resolve } from 'node:path';
23+
import { after, afterEach, before, test } from 'node:test';
24+
import { pathToFileURL } from 'node:url';
25+
import { build } from 'esbuild';
26+
import { parseHTML } from 'linkedom';
27+
import { act, createElement, type ComponentType, type ReactNode } from 'react';
28+
import { createRoot, type Root } from 'react-dom/client';
29+
import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui';
30+
import type { UiLocale } from '@maka/core/ui-locale';
31+
import type { ProviderType } from '@maka/core/llm-connections';
32+
import type { PeerMeshQueryResult } from '@maka/runtime-host/protocol';
33+
import type { RuntimeHostPeerConnectionPath } from '@maka/runtime-host/client';
34+
import type { DesktopRuntimeHostProfileSnapshot } from '../../preload/bridge-contract.js';
35+
import type { ConnectionsBridge } from '../../renderer/features/connection-settings/index.js';
36+
import type { RuntimeHostManagementServices } from '../../renderer/features/runtime-host-management/index.js';
37+
38+
// Keep renderer implementations and their asset imports out of the main compilation graph.
39+
interface RenderModules {
40+
AddProviderForm: ComponentType<{
41+
bridge: ConnectionsBridge;
42+
providerType: ProviderType;
43+
existingSlugs: string[];
44+
onCancel(): void;
45+
onCreated(slug: string, modelDiscoveryError?: unknown): Promise<void>;
46+
}>;
47+
RuntimeHostProfilesSection: ComponentType<{
48+
onRemoteHostAdded(profileId: string): void;
49+
}>;
50+
RuntimeHostManagementServicesProvider: ComponentType<{
51+
services: RuntimeHostManagementServices;
52+
children?: ReactNode;
53+
}>;
54+
RuntimeHostPeerMeshDialog: ComponentType<{
55+
target: Parameters<RuntimeHostManagementServices['peerMesh']['execute']>[0];
56+
targetName: string;
57+
onClose(): void;
58+
}>;
59+
}
60+
61+
let components: RenderModules;
62+
let bundleDirectory: string;
63+
let mountedRoot: Root | undefined;
64+
const originalGlobals = {
65+
document: globalThis.document,
66+
window: globalThis.window,
67+
HTMLElement: globalThis.HTMLElement,
68+
HTMLIFrameElement: globalThis.HTMLIFrameElement,
69+
Node: globalThis.Node,
70+
Event: globalThis.Event,
71+
CSS: globalThis.CSS,
72+
matchMedia: globalThis.matchMedia,
73+
getComputedStyle: globalThis.getComputedStyle,
74+
requestAnimationFrame: globalThis.requestAnimationFrame,
75+
cancelAnimationFrame: globalThis.cancelAnimationFrame,
76+
IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
77+
.IS_REACT_ACT_ENVIRONMENT,
78+
};
79+
80+
before(async () => {
81+
const repoRoot = resolve(import.meta.dirname, '../../../../..');
82+
bundleDirectory = await mkdtemp(resolve(repoRoot, 'apps/desktop/dist/main/__tests__/connection-locale-'));
83+
const outfile = resolve(bundleDirectory, 'components.mjs');
84+
// Like password-input.test.ts, bundle extensionless renderer imports without replacing components.
85+
await build({
86+
stdin: {
87+
contents: [
88+
"export { AddProviderForm } from './settings/provider-add-form';",
89+
"export { RuntimeHostProfilesSection } from './settings/runtime-host-profiles-section';",
90+
"export { RuntimeHostManagementServicesProvider, RuntimeHostPeerMeshDialog } from './features/runtime-host-management/index';",
91+
].join('\n'),
92+
resolveDir: resolve(repoRoot, 'apps/desktop/src/renderer'),
93+
},
94+
outfile,
95+
bundle: true,
96+
packages: 'external',
97+
loader: { '.svg': 'dataurl' },
98+
platform: 'node',
99+
format: 'esm',
100+
jsx: 'automatic',
101+
target: 'node20',
102+
logLevel: 'silent',
103+
});
104+
components = await import(pathToFileURL(outfile).href) as RenderModules;
105+
});
106+
107+
afterEach(async () => {
108+
try {
109+
if (mountedRoot) await act(() => mountedRoot?.unmount());
110+
} finally {
111+
mountedRoot = undefined;
112+
Object.assign(globalThis, originalGlobals);
113+
}
114+
});
115+
116+
after(async () => {
117+
if (bundleDirectory) await rm(bundleDirectory, { recursive: true, force: true });
118+
});
119+
120+
const localeCases = [
121+
{
122+
locale: 'zh-CN', direct: '直连', transit: '成员转发', save: '保存供应商',
123+
slugErrors: {
124+
required: '请填写连接标识',
125+
format: '连接标识只能包含小写字母、数字和连字符',
126+
too_long: '连接标识不能超过 64 个字符',
127+
duplicate: '连接标识已存在',
128+
},
129+
},
130+
{
131+
locale: 'zh-TW', direct: '直接連線', transit: '成員轉送', save: '儲存供應商',
132+
slugErrors: {
133+
required: '請填寫連線標識',
134+
format: '連線標識只能包含小寫字母、數字和連字號',
135+
too_long: '連線標識不能超過 64 個字元',
136+
duplicate: '連線標識已存在',
137+
},
138+
},
139+
{
140+
locale: 'en', direct: 'Direct', transit: 'Member transit', save: 'Save provider',
141+
slugErrors: {
142+
required: 'Enter a connection identifier',
143+
format: 'Connection identifiers use lowercase letters, digits, and hyphens',
144+
too_long: 'Connection identifiers are at most 64 characters',
145+
duplicate: 'Connection identifier already exists',
146+
},
147+
},
148+
] as const;
149+
150+
for (const copy of localeCases) {
151+
test(`${copy.locale}: peer path badges describe the route, while tooltips retain the protocol`, async () => {
152+
const harness = installRenderer();
153+
const paths: RuntimeHostPeerConnectionPath[] = [
154+
{ kind: 'direct', transport: 'webrtc' },
155+
{ kind: 'direct', transport: 'quic' },
156+
{ kind: 'direct', transport: 'tcp' },
157+
{ kind: 'transit', relayPeerId: 'relay-peer' },
158+
];
159+
const snapshot: DesktopRuntimeHostProfileSnapshot = {
160+
defaultProfileId: 'local',
161+
entries: [{
162+
profile: { kind: 'local', id: 'local', name: 'Local' },
163+
enabled: true, isDefault: true, readiness: 'ready',
164+
}, ...paths.map<DesktopRuntimeHostProfileSnapshot['entries'][number]>((peerPath, index) => ({
165+
profile: {
166+
kind: 'remote', id: `remote-${index}`, name: `Remote ${index}`, rootId: 'root',
167+
transport: {
168+
kind: 'libp2p-direct',
169+
reachability: {
170+
lease: {
171+
version: 1, peerId: `peer-${index}`, revision: 1,
172+
issuedAt: 1, expiresAt: 300001, directRoutes: [], coordinationRoutes: [],
173+
},
174+
publicKey: 'test-public-key', signature: 'test-signature',
175+
},
176+
},
177+
},
178+
enabled: true, isDefault: false, readiness: 'ready', peerPath,
179+
}))],
180+
};
181+
Object.assign(window, {
182+
maka: {
183+
runtimeHostProfiles: {
184+
getSnapshot: async () => snapshot,
185+
subscribeChanges: () => () => {},
186+
},
187+
localRuntimeHostRemoteAccess: { getSnapshot: async () => ({ state: 'off' }) },
188+
runtimeHostManagement: { subscribeProgress: () => () => {} },
189+
},
190+
});
191+
await harness.render(copy.locale, createElement(components.RuntimeHostProfilesSection, {
192+
onRemoteHostAdded: unexpectedCall,
193+
}));
194+
195+
const details = [
196+
`${copy.direct} · WebRTC`, `${copy.direct} · QUIC`, `${copy.direct} · TCP`,
197+
`${copy.transit} · relay-peer`,
198+
];
199+
for (const [index, detail] of details.entries()) {
200+
const row = [...harness.document.querySelectorAll('li')].find(
201+
(element) => element.textContent.includes(`Remote ${index}`),
202+
);
203+
assert.ok(row, `missing Remote ${index}`);
204+
const trigger = [...row.querySelectorAll('span[aria-describedby]')].find(
205+
(element) => describedElements(element).some(
206+
(description) => description.getAttribute('role') === 'tooltip' && description.textContent === detail,
207+
),
208+
);
209+
assert.ok(trigger, `missing rendered tooltip: ${detail}`);
210+
assert.equal(trigger.textContent, index === 3 ? copy.transit : copy.direct);
211+
}
212+
});
213+
214+
for (const [reason, slug] of Object.entries({
215+
required: '', format: 'Not A Slug', too_long: 'a'.repeat(65), duplicate: 'taken',
216+
})) {
217+
test(`${copy.locale}: AddProviderForm renders the ${reason} slug error`, async () => {
218+
const harness = installRenderer();
219+
const calls: string[] = [];
220+
const bridge = {
221+
create: async () => { calls.push('create'); throw new Error('unexpected create'); },
222+
fetchModels: async () => { calls.push('fetchModels'); throw new Error('unexpected discovery'); },
223+
} as unknown as ConnectionsBridge;
224+
await harness.render(copy.locale, createElement(components.AddProviderForm, {
225+
bridge, providerType: 'openai-compatible', existingSlugs: ['taken'],
226+
onCancel: unexpectedCall, onCreated: unexpectedCall,
227+
}));
228+
const input = harness.document.querySelector<HTMLInputElement>('input[placeholder="my-provider"]');
229+
assert.ok(input, 'missing connection identifier input');
230+
await act(async () => {
231+
input.value = slug;
232+
const key = Object.keys(input).find((candidate) => candidate.startsWith('__reactProps$'));
233+
assert.ok(key, 'missing React input props');
234+
const props = (input as unknown as Record<string, unknown>)[key] as {
235+
onChange(event: { target: HTMLInputElement; defaultPrevented: boolean }): void;
236+
};
237+
props.onChange({ target: input, defaultPrevented: false });
238+
});
239+
const submit = [...harness.document.querySelectorAll('button')].find(
240+
(button) => button.textContent === copy.save,
241+
);
242+
assert.ok(submit, 'missing save button');
243+
await act(async () => submit.click());
244+
245+
assert.equal(input.getAttribute('aria-invalid'), 'true');
246+
assert.deepEqual(describedElements(input).map((element) => element.textContent), [
247+
copy.slugErrors[reason as keyof typeof copy.slugErrors],
248+
]);
249+
assert.deepEqual(calls, [], 'invalid identifiers must not reach the provider bridge');
250+
});
251+
}
252+
}
253+
254+
test('zh-TW: expanded Peer Mesh members render localized route states', async () => {
255+
const harness = installRenderer();
256+
const states = ['local', 'connecting', 'reachable', 'reconnecting', 'needs_repair'] as const;
257+
const snapshot: PeerMeshQueryResult = {
258+
available: true, localPeerId: 'peer-local',
259+
meshes: [{
260+
meshId: 'mesh-1', displayName: 'Test Mesh', role: 'authority',
261+
authorityPeerId: 'peer-local', revision: 1, closed: false, pendingInvitationCount: 0,
262+
members: states.map((state) => ({ peerId: `peer-${state}`, state, endpointKind: 'host' })),
263+
}],
264+
};
265+
const services = managementServices();
266+
services.peerMesh.execute = async (_target, action) => {
267+
assert.equal(action, 'status');
268+
return snapshot;
269+
};
270+
await harness.render('zh-TW', createElement(components.RuntimeHostPeerMeshDialog, {
271+
target: { kind: 'local_host' }, targetName: 'Local Host', onClose: unexpectedCall,
272+
}), services);
273+
const disclosure = harness.document.querySelector<HTMLButtonElement>('.settingsPeerMeshCardDisclosure');
274+
assert.ok(disclosure, 'missing mesh disclosure');
275+
await act(async () => disclosure.click());
276+
277+
const expected = [
278+
'本機 Runtime Host · 管理者', '正在連線', '可連線', '正在恢復連線', '需要新邀請碼修復',
279+
];
280+
const members = [...harness.document.querySelectorAll('.settingsPeerMeshMember')];
281+
assert.equal(members.length, states.length);
282+
for (const [index, member] of members.entries()) {
283+
const heading = member.querySelector('.settingsPeerMeshMemberHeading');
284+
assert.ok(heading);
285+
assert.equal(heading.nextElementSibling?.textContent, expected[index], states[index]);
286+
}
287+
});
288+
289+
function unexpectedCall(): never {
290+
assert.fail('unexpected service call');
291+
}
292+
293+
function managementServices(): RuntimeHostManagementServices {
294+
return {
295+
supportsWsl: false,
296+
profilePairing: { retry: unexpectedCall, discard: unexpectedCall },
297+
connectionCodes: {
298+
create: unexpectedCall, importCode: unexpectedCall,
299+
readClipboardText: unexpectedCall, writeClipboardText: unexpectedCall,
300+
},
301+
resources: { query: unexpectedCall, schedule: unexpectedCall },
302+
peerMesh: {
303+
execute: unexpectedCall, cancel: unexpectedCall,
304+
getConnectivityPolicy: unexpectedCall, setConnectivityPolicy: unexpectedCall,
305+
getDirectPeer: unexpectedCall, configureDirectPeer: unexpectedCall, copyText: unexpectedCall,
306+
createOperationId: () => 'status-operation', schedule: () => () => {},
307+
},
308+
};
309+
}
310+
311+
function describedElements(element: Element): Element[] {
312+
return (element.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean).map((id) => {
313+
const description = element.ownerDocument.getElementById(id);
314+
assert.ok(description, `missing description: ${id}`);
315+
return description;
316+
});
317+
}
318+
319+
function installRenderer() {
320+
const { document, window } = parseHTML('<html><body><div id="root"></div></body></html>');
321+
const matchMedia = (media: string) => ({
322+
matches: false, media, onchange: null,
323+
addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {},
324+
dispatchEvent: () => false,
325+
});
326+
const getComputedStyle = () => ({
327+
direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '',
328+
}) as unknown as CSSStyleDeclaration;
329+
Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} });
330+
Object.assign(window.HTMLElement.prototype, {
331+
showModal(this: HTMLElement) { this.setAttribute('open', ''); },
332+
close(this: HTMLElement) { this.removeAttribute('open'); },
333+
});
334+
Object.assign(globalThis, {
335+
document, window, matchMedia, getComputedStyle,
336+
HTMLElement: window.HTMLElement,
337+
HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
338+
Event: window.Event, Node: window.Node, CSS: { escape: (value: string) => value },
339+
requestAnimationFrame: () => 0, cancelAnimationFrame: () => {},
340+
IS_REACT_ACT_ENVIRONMENT: true,
341+
});
342+
const container = document.getElementById('root');
343+
assert.ok(container);
344+
const root = createRoot(container);
345+
mountedRoot = root;
346+
return {
347+
document,
348+
async render(locale: UiLocale, children: ReactNode, services = managementServices()) {
349+
await act(async () => root.render(createElement(LocaleProvider, {
350+
locale,
351+
children: createElement(AstryxLocaleProvider, {
352+
children: createElement(ToastProvider, {
353+
children: createElement(components.RuntimeHostManagementServicesProvider, { services, children }),
354+
}),
355+
}),
356+
})));
357+
},
358+
};
359+
}

apps/desktop/src/main/__tests__/provider-add-submission.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,18 @@ test('a create failure propagates instead of being reported as a discovery probl
221221
);
222222
});
223223

224+
test('the field gate preserves stable slug validation issues', () => {
225+
for (const [slug, detail] of [
226+
['', 'required'],
227+
['Not A Slug', 'format'],
228+
['a'.repeat(65), 'too_long'],
229+
]) {
230+
assert.deepEqual(validateAddProviderDraft(draft({ slug })), {
231+
field: 'slug', reason: 'invalid', detail,
232+
});
233+
}
234+
});
235+
224236
test('the field gate still reports the rules that survived', () => {
225237
assert.deepEqual(validateAddProviderDraft(draft({ slug: 'Not A Slug' }))?.field, 'slug');
226238
assert.deepEqual(validateAddProviderDraft(draft({ existingSlugs: ['house-relay'] })), {

0 commit comments

Comments
 (0)