From 82bb1c2d7701d3a3a44e77dad85763aa13407fac Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Sun, 9 Nov 2025 21:32:37 +0800 Subject: [PATCH] Fix/hibernate (#652) * feat: allow use local tiddlywiki version closes #536 * test: hibernate * fix: Ensure wiki worker is started before setting active view for hibernated wikiu * fix: injection --- features/hibernation.feature | 73 +++++++++ features/stepDefinitions/cleanup.ts | 6 +- features/stepDefinitions/wiki.ts | 153 +++++++++++++++++- src/constants/paths.ts | 21 +++ .../WorkspaceSelectorBase.tsx | 1 + src/services/wiki/index.ts | 8 +- .../wiki/wikiWorker/startNodeJSWiki.ts | 8 + src/services/workspacesView/index.ts | 17 +- src/windows/AddWorkspace/ExistedWikiForm.tsx | 2 +- src/windows/EditWorkspace/index.tsx | 5 +- 10 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 features/hibernation.feature diff --git a/features/hibernation.feature b/features/hibernation.feature new file mode 100644 index 00000000..014060d3 --- /dev/null +++ b/features/hibernation.feature @@ -0,0 +1,73 @@ +@hibernation +Feature: Workspace Hibernation + As a user + I want to be able to hibernate workspaces + So that I can save system resources when workspaces are not in use + + Background: + Given I cleanup test wiki so it could create a new one on start + And I launch the TidGi application + And I wait for the page to load completely + Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + # Create a second wiki workspace programmatically for hibernation testing + When I create a new wiki workspace with name "wiki2" + And I wait for 1 seconds for "wiki2 workspace icon to appear" + Then I should see a "wiki2 workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki2')" + + Scenario: Hibernate both workspaces and verify switching with wake up (issues #556 and #593) + # Enable hibernation for both wiki workspaces + # Enable for wiki + When I open edit workspace window for workspace with name "wiki" + And I switch to "editWorkspace" window + And I wait for the page to load completely + When I click on "misc options accordion and hibernation switch" elements with selectors: + | [data-testid='preference-section-miscOptions'] | + | [data-testid='hibernate-when-unused-switch'] | + When I click on a "save button" element with selector "[data-testid='edit-workspace-save-button']" + Then I should not see a "save button" element with selector "[data-testid='edit-workspace-save-button']" + Then I switch to "main" window + When I close "editWorkspace" window + # Enable hibernation for wiki2 + When I open edit workspace window for workspace with name "wiki2" + And I switch to "editWorkspace" window + And I wait for the page to load completely + When I click on "misc options accordion and hibernation switch" elements with selectors: + | [data-testid='preference-section-miscOptions'] | + | [data-testid='hibernate-when-unused-switch'] | + When I click on a "save button" element with selector "[data-testid='edit-workspace-save-button']" + Then I should not see a "save button" element with selector "[data-testid='edit-workspace-save-button']" + Then I switch to "main" window + When I close "editWorkspace" window + # Start with wiki, create a test tiddler to verify workspace content + When I click on a "wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + # Create a test tiddler in wiki workspace + And I click on "add tiddler button" element in browser view with selector "button:has(.tc-image-new-button)" + And I click on "title input" element in browser view with selector "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor" + And I wait for 0.2 seconds + And I press "Control+a" in browser view + And I wait for 0.2 seconds + And I press "Delete" in browser view + And I type "WikiTestTiddler" in "title input" element in browser view with selector "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor" + # Confirm to save the tiddler + And I click on "confirm button" element in browser view with selector "button:has(.tc-image-done-button)" + And I wait for 0.2 seconds + Then I should see a "WikiTestTiddler tiddler" element in browser view with selector "div[data-tiddler-title='WikiTestTiddler']" + # Switch to wiki2 - wiki should hibernate, wiki2 should load + When I click on a "wiki2 workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki2')" + Then the browser view should be loaded and visible + # Verify wiki workspace is now hibernated (icon should be grayed out) + Then I should see a "wiki workspace hibernated icon" element with selector "div[data-testid^='workspace-']:has-text('wiki')[data-hibernated='true']" + # Verify we're in wiki2 by checking Index tiddler (default open) - not WikiTestTiddler + Then I should see a "Index tiddler" element in browser view with selector "div[data-tiddler-title='Index']" + Then I should not see a "WikiTestTiddler tiddler" element in browser view with selector "div[data-tiddler-title='WikiTestTiddler']" + # Switch back to wiki - wiki2 should hibernate, wiki should wake up (reproduces issue #556) + # This also tests issue #593 - browser view should persist after wake up + When I click on a "wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + # Verify wiki2 workspace is now hibernated + Then I should see a "wiki2 workspace hibernated icon" element with selector "div[data-testid^='workspace-']:has-text('wiki2')[data-hibernated='true']" + # Verify wiki workspace is no longer hibernated + Then I should see a "wiki workspace active icon" element with selector "div[data-testid^='workspace-']:has-text('wiki')[data-hibernated='false'][data-active='true']" + # Verify WikiTestTiddler is still there after wake up + Then I should see a "WikiTestTiddler tiddler" element in browser view with selector "div[data-tiddler-title='WikiTestTiddler']" diff --git a/features/stepDefinitions/cleanup.ts b/features/stepDefinitions/cleanup.ts index 661c30dd..b09be2ee 100644 --- a/features/stepDefinitions/cleanup.ts +++ b/features/stepDefinitions/cleanup.ts @@ -4,7 +4,7 @@ import { logsDirectory, screenshotsDirectory } from '../supports/paths'; import { clearAISettings } from './agent'; import { ApplicationWorld } from './application'; import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow'; -import { clearGitTestData, clearSubWikiRoutingTestData } from './wiki'; +import { clearGitTestData, clearHibernationTestData, clearSubWikiRoutingTestData } from './wiki'; Before(async function(this: ApplicationWorld, { pickle }) { // Create necessary directories under userData-test/logs to match appPaths in dev/test @@ -67,6 +67,10 @@ After(async function(this: ApplicationWorld, { pickle }) { if (pickle.tags.some((tag) => tag.name === '@git')) { await clearGitTestData(); } + // Clean up hibernation test data - remove wiki2 folder created during tests + if (pickle.tags.some((tag) => tag.name === '@hibernation')) { + await clearHibernationTestData(); + } // Separate logs by test scenario for easier debugging try { diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index ba6206d5..1f71afd9 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -466,4 +466,155 @@ When('I modify file {string} to add field {string}', async function(this: Applic await fs.writeFile(actualPath, lines.join('\n'), 'utf-8'); }); -export { clearGitTestData, clearSubWikiRoutingTestData }; +When('I open edit workspace window for workspace with name {string}', async function(this: ApplicationWorld, workspaceName: string) { + if (!this.app) { + throw new Error('Application is not available'); + } + + // Read settings file to get workspace info + const settings = await fs.readJson(settingsPath) as { workspaces?: Record }; + const workspaces: Record = settings.workspaces ?? {}; + + // Find workspace by name + let targetWorkspaceId: string | undefined; + for (const [id, workspace] of Object.entries(workspaces)) { + if (!workspace.pageType && workspace.name === workspaceName) { + targetWorkspaceId = id; + break; + } + } + + if (!targetWorkspaceId) { + throw new Error(`No workspace found with name: ${workspaceName}`); + } + + // Call window service through main window's webContents to open edit workspace window + await this.app.evaluate(async ({ BrowserWindow }, workspaceId: string) => { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); + + if (!mainWindow) { + throw new Error('Main window not found'); + } + + // Call the window service to open edit workspace window + // Safely pass workspaceId using JSON serialization to avoid string interpolation vulnerability + await mainWindow.webContents.executeJavaScript(` + (async () => { + await window.service.window.open('editWorkspace', { workspaceID: ${JSON.stringify(workspaceId)} }); + })(); + `); + }, targetWorkspaceId); + + // Wait for the edit workspace window to appear + const success = await this.waitForWindowCondition( + 'editWorkspace', + (window) => window !== undefined && !window.isClosed(), + ); + + if (!success) { + throw new Error('Edit workspace window did not appear after opening'); + } +}); + +When('I create a new wiki workspace with name {string}', async function(this: ApplicationWorld, workspaceName: string) { + if (!this.app) { + throw new Error('Application is not available'); + } + + // Construct the full wiki path + const wikiPath = path.join(wikiTestRootPath, workspaceName); + + // Create the wiki folder using the template + const templatePath = path.join(process.cwd(), 'template', 'wiki'); + await fs.copy(templatePath, wikiPath); + + // Remove the copied .git directory from the template to start fresh + const gitPath = path.join(wikiPath, '.git'); + await fs.remove(gitPath).catch(() => { + // Ignore if .git doesn't exist + }); + + // Initialize fresh git repository for the new wiki + const { execSync } = await import('child_process'); + try { + execSync('git init', { cwd: wikiPath }); + execSync('git config user.email "test@tidgi.test"', { cwd: wikiPath }); + execSync('git config user.name "TidGi Test"', { cwd: wikiPath }); + execSync('git add .', { cwd: wikiPath }); + execSync('git commit -m "Initial commit"', { cwd: wikiPath }); + } catch (error) { + // Git initialization is not critical for the test, continue anyway + console.log('Git initialization skipped:', (error as Error).message); + } + + // Now create workspace configuration + await this.app.evaluate(async ({ BrowserWindow }, { wikiName, wikiFullPath }: { wikiName: string; wikiFullPath: string }) => { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html')); + + if (!mainWindow) { + throw new Error('Main window not found'); + } + + // Call workspace service to create new workspace + // Safely pass parameters using JSON serialization to avoid string interpolation vulnerability + await mainWindow.webContents.executeJavaScript(` + (async () => { + await window.service.workspace.create({ + name: ${JSON.stringify(wikiName)}, + wikiFolderLocation: ${JSON.stringify(wikiFullPath)}, + isSubWiki: false, + storageService: 'local', + }); + })(); + `); + }, { wikiName: workspaceName, wikiFullPath: wikiPath }); + + // Wait for workspace to appear in UI + await this.app.evaluate(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + }); +}); + +/** + * Clean up hibernation test data - remove wiki2 folder and its workspace config + */ +async function clearHibernationTestData() { + const wiki2Path = path.join(wikiTestRootPath, 'wiki2'); + + // Remove wiki2 folder + if (await fs.pathExists(wiki2Path)) { + try { + await fs.remove(wiki2Path); + } catch (error) { + console.warn('Failed to remove wiki2 folder in hibernation cleanup:', error); + } + } + + // Remove wiki2 workspace config from settings.json + const settingsPath = path.join(process.cwd(), 'userData-test', 'settings', 'settings.json'); + if (await fs.pathExists(settingsPath)) { + try { + type SettingsFile = { workspaces?: Record } & Record; + const settings = await fs.readJson(settingsPath) as SettingsFile; + if (settings.workspaces) { + // Find and remove wiki2 workspace by folder location + const wiki2WorkspaceId = Object.keys(settings.workspaces).find(id => { + const workspace = settings.workspaces?.[id]; + return workspace && 'wikiFolderLocation' in workspace && workspace.wikiFolderLocation === wiki2Path; + }); + + if (wiki2WorkspaceId && settings.workspaces) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete settings.workspaces[wiki2WorkspaceId]; + await fs.writeJson(settingsPath, settings, { spaces: 2 }); + } + } + } catch (error) { + console.warn('Failed to remove wiki2 workspace config in hibernation cleanup:', error); + } + } +} + +export { clearGitTestData, clearHibernationTestData, clearSubWikiRoutingTestData }; diff --git a/src/constants/paths.ts b/src/constants/paths.ts index 53d71aee..725ca86d 100644 --- a/src/constants/paths.ts +++ b/src/constants/paths.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs'; import os from 'os'; import path from 'path'; import { isMac } from '../helpers/system'; @@ -48,6 +49,26 @@ export const ZX_FOLDER = path.resolve(PACKAGE_PATH_BASE, 'zx', 'build', 'cli.js' export const TIDDLYWIKI_PACKAGE_FOLDER = path.resolve(PACKAGE_PATH_BASE, 'tiddlywiki', 'boot'); export const SQLITE_BINARY_PATH = path.resolve(PACKAGE_PATH_BASE, 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node'); +/** + * Check if a wiki folder has its own TiddlyWiki installation and return the appropriate boot path. + * Prefers wiki-folder-local installation over the built-in version to support custom TW versions. + * + * @param wikiFolderLocation - The path to the wiki folder + * @returns The path to TiddlyWiki boot folder (local if exists, otherwise built-in) + */ +export function getTiddlyWikiBootPath(wikiFolderLocation: string): string { + const localTiddlyWikiBootPath = path.resolve(wikiFolderLocation, 'node_modules', 'tiddlywiki', 'boot'); + try { + // Check if local TiddlyWiki exists synchronously since this is a critical path + if (existsSync(localTiddlyWikiBootPath)) { + return localTiddlyWikiBootPath; + } + } catch { + // Fall through to use built-in version if check fails + } + return TIDDLYWIKI_PACKAGE_FOLDER; +} + // Localization folder export const LOCALIZATION_FOLDER = isPackaged ? path.resolve(process.resourcesPath, localizationFolderName) // Packaged: resources/localization diff --git a/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx b/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx index 63a47416..a1d16efc 100644 --- a/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx +++ b/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx @@ -191,6 +191,7 @@ export function WorkspaceSelectorBase({ onClick={workspaceClickedLoading ? () => {} : onClick} data-testid={pageType ? `workspace-${pageType}` : `workspace-${id}`} data-active={active ? 'true' : 'false'} + data-hibernated={hibernated ? 'true' : 'false'} > {icon} diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index a5562992..b08be0e1 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -13,7 +13,7 @@ import WikiWorkerFactory from './wikiWorker/index?nodeWorker'; import { container } from '@services/container'; import { WikiChannel } from '@/constants/channels'; -import { TIDDLERS_PATH, TIDDLYWIKI_PACKAGE_FOLDER, TIDDLYWIKI_TEMPLATE_FOLDER_PATH } from '@/constants/paths'; +import { getTiddlyWikiBootPath, TIDDLERS_PATH, TIDDLYWIKI_TEMPLATE_FOLDER_PATH } from '@/constants/paths'; import type { IAuthenticationService } from '@services/auth/interface'; import type { IGitService, IGitUserInfos } from '@services/git/interface'; import { i18n } from '@services/libs/i18n'; @@ -136,7 +136,7 @@ export class Wiki implements IWikiService { const shouldUseDarkColors = await this.themeService.shouldUseDarkColors(); const workerData: IStartNodeJSWikiConfigs = { authToken, - constants: { TIDDLYWIKI_PACKAGE_FOLDER }, + constants: { TIDDLYWIKI_PACKAGE_FOLDER: getTiddlyWikiBootPath(wikiFolderLocation) }, enableHTTPAPI, excludedPlugins, homePath: wikiFolderLocation, @@ -352,7 +352,7 @@ export class Wiki implements IWikiService { if (await exists(saveWikiFolderPath)) { throw new AlreadyExistError(saveWikiFolderPath); } - await worker.extractWikiHTML(htmlWikiPath, saveWikiFolderPath, { TIDDLYWIKI_PACKAGE_FOLDER }); + await worker.extractWikiHTML(htmlWikiPath, saveWikiFolderPath, { TIDDLYWIKI_PACKAGE_FOLDER: getTiddlyWikiBootPath(saveWikiFolderPath) }); } catch (error) { const result = `${(error as Error).name} ${(error as Error).message}`; logger.error(result, { worker: 'NodeJSWiki', method: 'extractWikiHTML', htmlWikiPath, saveWikiFolderPath }); @@ -369,7 +369,7 @@ export class Wiki implements IWikiService { const worker = createWorkerProxy(nativeWorker); try { - await worker.packetHTMLFromWikiFolder(wikiFolderLocation, pathOfNewHTML, { TIDDLYWIKI_PACKAGE_FOLDER }); + await worker.packetHTMLFromWikiFolder(wikiFolderLocation, pathOfNewHTML, { TIDDLYWIKI_PACKAGE_FOLDER: getTiddlyWikiBootPath(wikiFolderLocation) }); } finally { // this worker is only for one time use. we will spawn a new one for starting wiki later. await terminateWorker(nativeWorker); diff --git a/src/services/wiki/wikiWorker/startNodeJSWiki.ts b/src/services/wiki/wikiWorker/startNodeJSWiki.ts index 962f226c..fca88abe 100644 --- a/src/services/wiki/wikiWorker/startNodeJSWiki.ts +++ b/src/services/wiki/wikiWorker/startNodeJSWiki.ts @@ -74,6 +74,14 @@ export function startNodeJSWiki({ observer.next({ type: 'control', actions: WikiControlActions.start, argv: fullBootArgv }); try { + // Log which TiddlyWiki version is being used (local vs built-in) + const isUsingLocalTiddlyWiki = TIDDLYWIKI_PACKAGE_FOLDER.includes(path.join(homePath, 'node_modules')); + void native.logFor( + workspace.name, + 'info', + `Starting TiddlyWiki from ${isUsingLocalTiddlyWiki ? 'wiki-local installation' : 'built-in installation'}: ${TIDDLYWIKI_PACKAGE_FOLDER}`, + ); + const wikiInstance = TiddlyWiki(); setWikiInstance(wikiInstance); process.env.TIDDLYWIKI_PLUGIN_PATH = path.resolve(homePath, 'plugins'); diff --git a/src/services/workspacesView/index.ts b/src/services/workspacesView/index.ts index efc68e87..98d1e9cd 100644 --- a/src/services/workspacesView/index.ts +++ b/src/services/workspacesView/index.ts @@ -294,13 +294,16 @@ export class WorkspaceView implements IWorkspaceViewService { public async wakeUpWorkspaceView(workspaceID: string): Promise { const workspace = await container.get(serviceIdentifier.Workspace).get(workspaceID); if (workspace !== undefined) { + // First, update workspace state and start wiki server await Promise.all([ container.get(serviceIdentifier.Workspace).update(workspaceID, { hibernated: false, }), this.authService.getUserName(workspace).then(userName => container.get(serviceIdentifier.Wiki).startWiki(workspaceID, userName)), - this.addViewForAllBrowserViews(workspace), ]); + + // Then add view after wiki server is ready and workspace is marked as not hibernated + await this.addViewForAllBrowserViews(workspace); } } @@ -366,6 +369,17 @@ export class WorkspaceView implements IWorkspaceViewService { if (isWikiWorkspace(newWorkspace) && newWorkspace.hibernated) { await this.wakeUpWorkspaceView(nextWorkspaceID); } + + // fix #556 and #593: Ensure wiki worker is started before setting active view. When switching to a wiki workspace that doesn't have a view yet, the view service will create one and immediately try to loadURL. If the wiki worker hasn't started, loadURL will hang forever waiting for the IPC server that never comes online. This must happen before `setActiveViewForAllBrowserViews` to ensure the worker is ready when view is created. + if (isWikiWorkspace(newWorkspace) && !newWorkspace.hibernated) { + const wikiService = container.get(serviceIdentifier.Wiki); + const worker = wikiService.getWorker(nextWorkspaceID); + if (worker === undefined) { + const userName = await this.authService.getUserName(newWorkspace); + await wikiService.startWiki(nextWorkspaceID, userName); + } + } + try { await container.get(serviceIdentifier.View).setActiveViewForAllBrowserViews(nextWorkspaceID); await this.realignActiveWorkspace(nextWorkspaceID); @@ -377,6 +391,7 @@ export class WorkspaceView implements IWorkspaceViewService { throw error; } // if we are switching to a new workspace, we hide and/or hibernate old view, and activate new view + // This must happen after view setup succeeds to avoid issues with workspace that hasn't started yet if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) { await this.hideWorkspaceView(oldActiveWorkspace.id); if (isWikiWorkspace(oldActiveWorkspace) && oldActiveWorkspace.hibernateWhenUnused) { diff --git a/src/windows/AddWorkspace/ExistedWikiForm.tsx b/src/windows/AddWorkspace/ExistedWikiForm.tsx index 3ca3f6c7..3cee68bc 100644 --- a/src/windows/AddWorkspace/ExistedWikiForm.tsx +++ b/src/windows/AddWorkspace/ExistedWikiForm.tsx @@ -69,7 +69,7 @@ export function ExistedWikiForm({ // Update local state immediately for responsive UI const newValue = event.target.value; setFullPath(newValue); - + // Parse path into parent and folder for validation const lastSlashIndex = Math.max(newValue.lastIndexOf('/'), newValue.lastIndexOf('\\')); if (lastSlashIndex >= 0) { diff --git a/src/windows/EditWorkspace/index.tsx b/src/windows/EditWorkspace/index.tsx index 4da4eac1..06b57d0e 100644 --- a/src/windows/EditWorkspace/index.tsx +++ b/src/windows/EditWorkspace/index.tsx @@ -395,7 +395,7 @@ export default function EditWorkspace(): React.JSX.Element { - }> + } data-testid='preference-section-miscOptions'> {t('EditWorkspace.MiscOptions')} @@ -410,6 +410,7 @@ export default function EditWorkspace(): React.JSX.Element { edge='end' color='primary' checked={hibernateWhenUnused} + data-testid='hibernate-when-unused-switch' onChange={(event: React.ChangeEvent) => { workspaceSetter({ ...workspace, hibernateWhenUnused: event.target.checked }); }} @@ -489,7 +490,7 @@ export default function EditWorkspace(): React.JSX.Element { {!isEqual(omit(workspace, nonConfigFields), omit(originalWorkspace, nonConfigFields)) && ( -