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
This commit is contained in:
lin onetwo 2025-11-09 21:32:37 +08:00 committed by GitHub
parent ed198d375b
commit 82bb1c2d77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 284 additions and 10 deletions

View file

@ -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']"

View file

@ -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 {

View file

@ -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<string, IWorkspace> };
const workspaces: Record<string, IWorkspace> = 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<string, IWorkspace> } & Record<string, unknown>;
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 };

View file

@ -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

View file

@ -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'}
>
<Badge color='secondary' badgeContent={badgeCount} max={99}>
{icon}

View file

@ -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<WikiWorker>(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);

View file

@ -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');

View file

@ -294,13 +294,16 @@ export class WorkspaceView implements IWorkspaceViewService {
public async wakeUpWorkspaceView(workspaceID: string): Promise<void> {
const workspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(workspaceID);
if (workspace !== undefined) {
// First, update workspace state and start wiki server
await Promise.all([
container.get<IWorkspaceService>(serviceIdentifier.Workspace).update(workspaceID, {
hibernated: false,
}),
this.authService.getUserName(workspace).then(userName => container.get<IWikiService>(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<IWikiService>(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<IViewService>(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) {

View file

@ -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) {

View file

@ -395,7 +395,7 @@ export default function EditWorkspace(): React.JSX.Element {
</OptionsAccordion>
<OptionsAccordion>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-miscOptions'>
{t('EditWorkspace.MiscOptions')}
</OptionsAccordionSummary>
</Tooltip>
@ -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<HTMLInputElement>) => {
workspaceSetter({ ...workspace, hibernateWhenUnused: event.target.checked });
}}
@ -489,7 +490,7 @@ export default function EditWorkspace(): React.JSX.Element {
</FlexGrow>
{!isEqual(omit(workspace, nonConfigFields), omit(originalWorkspace, nonConfigFields)) && (
<SaveCancelButtonsContainer>
<Button color='primary' variant='contained' disableElevation onClick={onSave}>
<Button color='primary' variant='contained' disableElevation onClick={onSave} data-testid='edit-workspace-save-button'>
{t('EditWorkspace.Save')}
</Button>
<Button variant='contained' disableElevation onClick={() => void window.remote.closeCurrentWindow()}>