Skip to content

Commit 17a9e3a

Browse files
committed
fix(html): keep workspace settings renderer-safe
Avoid Node globals in renderer-loaded workspace settings code and cover HTML workspace rename persistence.
1 parent 611b06a commit 17a9e3a

11 files changed

Lines changed: 77 additions & 36 deletions

File tree

features/htmlWikiWorkspace.feature

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,24 @@ Feature: HTML wiki workspace
105105
Then the HTML sync info should describe workspace "mobile-sync-wiki"
106106
When I PUT HTML sync file for workspace "mobile-sync-wiki" with content "<html><body>MobileSyncUpdated</body></html>"
107107
Then file "{tmpDir}/mobile-sync-wiki.html" should contain text "MobileSyncUpdated"
108+
109+
@html-wiki @settings
110+
Scenario: HTML workspace settings can rename the workspace and persist
111+
When I generate blank HTML wiki at "{tmpDir}/config-html.html"
112+
And I click on an "add workspace button" element with selector "#add-workspace-button"
113+
And I switch to "addWorkspace" window
114+
And I wait for the page to load completely
115+
When I click on a "open html wiki tab" element with selector "button:has-text('打开 HTML 知识库文件')"
116+
When I prepare to select file in dialog "wiki-test/config-html.html"
117+
When I click on a "choose html file button" element with selector "button:has-text('选择')"
118+
And I click on a "open html wiki done button" element with selector "[data-testid='open-html-wiki-done-button']"
119+
When I switch to "main" window
120+
Then I wait for "workspace created" log marker "[test-id-WORKSPACE_CREATED]"
121+
Then I wait for "html wiki started" log marker "[test-id-HTML_WIKI_STARTED]"
122+
When I open edit workspace window for workspace with name "config-html"
123+
And I switch to "editWorkspace" window
124+
And I wait for the page to load completely
125+
When I type "Renamed HTML Workspace" in "workspace name" element with selector "input[value='config-html']"
126+
And I click on a "save workspace button" element with selector "[data-testid='edit-workspace-save-button']"
127+
Then I should not see a "save workspace button" element with selector "[data-testid='edit-workspace-save-button']"
128+
Then settings.json should have workspace "Renamed HTML Workspace" with "name" set to "Renamed HTML Workspace"

src/helpers/testKeyboardShortcuts.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,10 @@
2525
* NativeService.registerKeyboardShortcut using Electron's globalShortcut API.
2626
*/
2727

28-
export function initTestKeyboardShortcutFallback(): () => void {
29-
const isTestEnvironment = process.env.NODE_ENV === 'test';
28+
export function initTestKeyboardShortcutFallback(isTestEnvironment: boolean): () => void {
3029
if (!isTestEnvironment) return () => {};
3130
void window.service.native.log('debug', 'Renderer(Test): initTestKeyboardShortcutFallback called', {
3231
isTestEnvironment,
33-
nodeEnv: process.env.NODE_ENV,
3432
});
3533

3634
let allShortcuts: Record<string, string> = {};

src/pages/Agent/components/Search/TemplateSearch.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,9 @@ export function TemplateSearch({ placeholder, onTemplateSelect, testId }: Templa
148148
return undefined;
149149
}
150150

151-
// Only skip in unit test environment, not E2E
151+
// Only skip in jsdom-style unit tests, not E2E.
152152
const ua = typeof navigator !== 'undefined' ? navigator.userAgent ?? '' : '';
153-
const isUnitTest = /jsdom/i.test(ua) && process.env.NODE_ENV === 'test' && typeof window.service === 'undefined';
153+
const isUnitTest = /jsdom/i.test(ua) && typeof window.service === 'undefined';
154154
if (isUnitTest) {
155155
return undefined;
156156
}

src/renderer.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,20 @@ import { Pages } from './windows';
3030
function App(): JSX.Element {
3131
const theme = useThemeObservable();
3232
useEffect(() => {
33-
if (process.env.NODE_ENV === 'test') {
33+
let cleanup: (() => void) | undefined;
34+
let cancelled = false;
35+
void (async () => {
36+
const isTest = await window.service.context.get('isTest');
37+
if (!isTest || cancelled) return;
3438
// Lazy-load test-only keyboard shortcut fallback to avoid any overhead in production
35-
void import('./helpers/testKeyboardShortcuts').then(({ initTestKeyboardShortcutFallback }) => {
36-
initTestKeyboardShortcutFallback();
37-
});
38-
}
39+
const { initTestKeyboardShortcutFallback } = await import('./helpers/testKeyboardShortcuts');
40+
if (cancelled) return;
41+
cleanup = initTestKeyboardShortcutFallback(isTest);
42+
})();
43+
return () => {
44+
cancelled = true;
45+
cleanup?.();
46+
};
3947
}, []);
4048

4149
return (

src/services/context/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isTest } from '@/constants/environment';
12
import { isElectronDevelopment } from '@/constants/isElectronDevelopment';
23
import { LOCALIZATION_FOLDER } from '@/constants/paths';
34
import { app, net } from 'electron';
@@ -18,6 +19,7 @@ export class ContextService implements IContextService {
1819
private readonly pathConstants: IPaths = { ...paths, ...appPaths };
1920
private readonly constants: IConstants = {
2021
isDevelopment: isElectronDevelopment,
22+
isTest,
2123
platform: process.platform,
2224
appVersion: app.getVersion(),
2325
appName: app.name,

src/services/context/interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface IConstants {
2626
appVersion: string;
2727
environmentVersions: NodeJS.ProcessVersions;
2828
isDevelopment: boolean;
29+
isTest: boolean;
2930
oSVersion: string;
3031
platform: string;
3132
}

src/services/workspaces/__tests__/workspacePaths.test.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import path from 'node:path';
21
import { describe, expect, it } from 'vitest';
32

43
import { getWorkspaceGitScope, getWorkspaceType, isHtmlWikiWorkspace, normalizeHtmlWorkspacePaths } from '@services/workspaces/workspacePaths';
@@ -33,9 +32,9 @@ describe('workspacePaths', () => {
3332
});
3433

3534
it('normalizes html workspace paths', () => {
36-
const normalized = normalizeHtmlWorkspacePaths('/data/my.wiki.html');
37-
expect(normalized.htmlFileLocation).toBe(path.resolve('/data/my.wiki.html'));
38-
expect(normalized.wikiFolderLocation).toBe(path.resolve('/data'));
35+
const normalized = normalizeHtmlWorkspacePaths('C:\\data\\my.wiki.html');
36+
expect(normalized.htmlFileLocation).toBe('C:/data/my.wiki.html');
37+
expect(normalized.wikiFolderLocation).toBe('C:/data');
3938
});
4039

4140
it('scopes git to a single html file', () => {
@@ -45,14 +44,14 @@ describe('workspacePaths', () => {
4544
active: false,
4645
order: 0,
4746
picturePath: null,
48-
wikiFolderLocation: '/data',
47+
wikiFolderLocation: 'C:\\data',
4948
workspaceType: WorkspaceType.html,
50-
htmlFileLocation: '/data/my.wiki.html',
49+
htmlFileLocation: 'C:\\data\\my.wiki.html',
5150
};
5251
expect(getWorkspaceGitScope(workspace)).toEqual({
53-
repoPath: path.resolve('/data'),
52+
repoPath: 'C:/data',
5453
managedRelativePath: 'my.wiki.html',
55-
managedAbsolutePath: path.resolve('/data/my.wiki.html'),
54+
managedAbsolutePath: 'C:/data/my.wiki.html',
5655
managedDisplayName: 'my.wiki.html',
5756
});
5857
});

src/services/workspaces/workspacePaths.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
1-
import path from 'node:path';
2-
31
import { isHtmlWiki } from '@/constants/fileNames';
42
import type { IWikiWorkspace, IWorkspace } from './interface';
53
import { WorkspaceType } from './workspaceType';
64

5+
function normalizePathSeparators(filePath: string): string {
6+
return filePath.replace(/\\/g, '/');
7+
}
8+
9+
function dirname(filePath: string): string {
10+
const normalized = normalizePathSeparators(filePath).replace(/\/+$/, '');
11+
const separatorIndex = normalized.lastIndexOf('/');
12+
if (separatorIndex <= 0) {
13+
return normalized;
14+
}
15+
return normalized.slice(0, separatorIndex);
16+
}
17+
18+
function basename(filePath: string): string {
19+
const normalized = normalizePathSeparators(filePath).replace(/\/+$/, '');
20+
return normalized.slice(normalized.lastIndexOf('/') + 1);
21+
}
22+
723
/** Local guard to avoid runtime circular import with interface.ts */
824
function isWikiWorkspace(workspace: IWorkspace): workspace is IWikiWorkspace {
925
return 'wikiFolderLocation' in workspace;
@@ -61,7 +77,7 @@ export function getHtmlFileLocation(workspace: IWikiWorkspace): string | undefin
6177
*/
6278
export function getWorkspaceContainerPath(workspace: IWikiWorkspace): string {
6379
if (isHtmlWikiWorkspace(workspace)) {
64-
return path.dirname(workspace.htmlFileLocation);
80+
return dirname(workspace.htmlFileLocation);
6581
}
6682
return workspace.wikiFolderLocation;
6783
}
@@ -81,9 +97,9 @@ export function getWorkspaceGitScope(workspace: IWorkspace): IWorkspaceGitScope
8197
return undefined;
8298
}
8399
if (isHtmlWikiWorkspace(workspace)) {
84-
const managedAbsolutePath = path.resolve(workspace.htmlFileLocation);
85-
const repoPath = path.dirname(managedAbsolutePath);
86-
const managedRelativePath = path.basename(managedAbsolutePath);
100+
const managedAbsolutePath = normalizePathSeparators(workspace.htmlFileLocation);
101+
const repoPath = dirname(managedAbsolutePath);
102+
const managedRelativePath = basename(managedAbsolutePath);
87103
return {
88104
repoPath,
89105
managedRelativePath,
@@ -92,19 +108,19 @@ export function getWorkspaceGitScope(workspace: IWorkspace): IWorkspaceGitScope
92108
};
93109
}
94110
return {
95-
repoPath: path.resolve(workspace.wikiFolderLocation),
96-
managedAbsolutePath: path.resolve(workspace.wikiFolderLocation),
97-
managedDisplayName: path.basename(workspace.wikiFolderLocation),
111+
repoPath: normalizePathSeparators(workspace.wikiFolderLocation),
112+
managedAbsolutePath: normalizePathSeparators(workspace.wikiFolderLocation),
113+
managedDisplayName: basename(workspace.wikiFolderLocation),
98114
};
99115
}
100116

101117
export function normalizeHtmlWorkspacePaths(htmlFileLocation: string): Pick<IHtmlWikiWorkspace, 'htmlFileLocation' | 'wikiFolderLocation'> {
102-
const resolvedHtml = path.resolve(htmlFileLocation);
118+
const resolvedHtml = normalizePathSeparators(htmlFileLocation);
103119
if (!isHtmlWiki(resolvedHtml)) {
104120
throw new Error(`Not a valid HTML wiki file: ${resolvedHtml}`);
105121
}
106122
return {
107123
htmlFileLocation: resolvedHtml,
108-
wikiFolderLocation: path.dirname(resolvedHtml),
124+
wikiFolderLocation: dirname(resolvedHtml),
109125
};
110126
}

src/windows/Preferences/GenericSchemaRenderer.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {
1919
IGenericStringItem,
2020
} from '@services/preferences/definitions/types';
2121
import { getCustomComponent } from './customComponentRegistry';
22-
import { DeferredSectionSkeleton, INITIAL_GENERIC_SECTION_COUNT, IS_TEST_ENV, matchesPlatform, SearchSectionLabel, toKebabCase } from './genericSchemaRendererShared';
22+
import { DeferredSectionSkeleton, INITIAL_GENERIC_SECTION_COUNT, matchesPlatform, SearchSectionLabel, toKebabCase } from './genericSchemaRendererShared';
2323
import { HighlightText } from './HighlightText';
2424
import { Paper, SectionTitle } from './PreferenceComponents';
2525

@@ -308,9 +308,8 @@ export function AllGenericSectionsRenderer<TRecord extends Record<string, unknow
308308
[hiddenSections, sections],
309309
);
310310

311-
const [visibleCount, setVisibleCount] = React.useState(IS_TEST_ENV ? visibleSections.length : initialSectionCount);
311+
const [visibleCount, setVisibleCount] = React.useState(initialSectionCount);
312312
React.useEffect(() => {
313-
if (IS_TEST_ENV) return;
314313
if (query.trim()) return;
315314
if (visibleCount >= visibleSections.length) return;
316315
const id = requestIdleCallback(() => {

src/windows/Preferences/SchemaRenderer.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,6 @@ interface IAllSectionsRendererProps {
418418
}
419419

420420
const INITIAL_SECTION_COUNT = 4;
421-
const IS_TEST_ENV = process.env.NODE_ENV === 'test';
422421

423422
/** Placeholder skeleton shown for deferred sections while waiting for idle time */
424423
function DeferredSectionSkeleton({ sectionRef }: { sectionRef?: React.RefObject<HTMLSpanElement | null> }): React.JSX.Element {
@@ -440,9 +439,8 @@ export function AllSectionsRenderer({ onNeedsRestart, sectionRefs, query = '' }:
440439
const { t } = useTranslation(['translation', 'agent']);
441440

442441
// All hooks must be called unconditionally before any conditional return.
443-
const [visibleCount, setVisibleCount] = React.useState(IS_TEST_ENV ? allSections.length : INITIAL_SECTION_COUNT);
442+
const [visibleCount, setVisibleCount] = React.useState(INITIAL_SECTION_COUNT);
444443
React.useEffect(() => {
445-
if (IS_TEST_ENV) return;
446444
if (query.trim()) return; // don't advance deferred loading while searching
447445
if (preference === undefined || visibleCount >= allSections.length) return;
448446
const id = requestIdleCallback(() => {

0 commit comments

Comments
 (0)