mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-05 18:20:39 -08:00
* feat: Skip restart if file system watch is enabled - the watcher will handle file changes automatically * fix: sometimes change sync interval not working fixes #310 * fix: Return false on sync failure - no successful changes were made fixes #558 * fix: step that is wrong * feat: monitoring subwiki * AI added waitForSSEReady * Revert "AI added waitForSSEReady" This reverts commit983b1c623c. * fix: error on frontend loading worker thread * fix * Update wiki.ts * auto reload view and click subwiki icon * Refactor sync echo prevention and improve logging Removed frontend-side echo prevention logic in ipcSyncAdaptor, relying solely on backend file exclusion for echo prevention. Improved console log wrappers to preserve native behavior and added a log statement to setupSSE. Updated test steps and file modification logic to better simulate external edits without modifying timestamps. Added internal documentation on sync architecture. * feat: deboucne and prevent data race when write file * Update watch-filesystem-adaptor.ts * rename camelcase * Update filesystemPlugin.feature * Fix sync interval timezone handling and add tests Refactored syncDebounceInterval logic in Sync.tsx to be timezone-independent, ensuring correct interval storage and display across all timezones. Added comprehensive tests in Sync.timezone.test.ts to verify correct behavior and document previous timezone-related bugs. fixes #310 * i18n for notification * Update index.tsx * fix: potential symlinks problem of subwiki * Update Sync.timezone.test.ts * lint * Implement backoff for file existence check Refactor file existence check to use backoff strategy and add directory tree retrieval for error reporting. * Update BACKOFF_OPTIONS with new configuration * Update wiki.ts * remove log * Update wiki.ts * fix: draft not move to sub * Update filesystemPlugin.feature * fix: routing tw logger to file * Update filesystemPlugin.feature * test: use id to check view load and sse load * Optimize test steps and screenshot logic Removed unnecessary short waits in filesystemPlugin.feature and increased wait time for tiddler state to settle. Updated application.ts to skip screenshots for wait steps, reducing redundant screenshots during test execution. * Check if the WebContents is actually loaded and remove fake webContentsViewHelper.new.ts created by AI * Update view.ts * fix: prevent echo by exclude title * test: Then file "Draft of '新条目'.tid" should not exist in "{tmpDir}/wiki/tiddlers" * Revert "fix: prevent echo by exclude title" This reverts commit86aa838d24. * fix: when move file to subwiki, delete old file * fix: prevent ipc echo change back to frontend * test: view might take longer to load * fix: minor issues * test: fix cleanup timeout * Update cleanup.ts * feat: capture webview screenshot * Update filesystemPlugin.feature * Update SyncArchitecture.md * rename * test: add some time to easy failed steps * Separate logs by test scenario for easier debugging * Update selectors for add and confirm buttons in tests Changed the CSS selectors for the add tiddler and confirm buttons in the filesystem plugin feature tests to use :has() with icon classes. This improves selector robustness and aligns with UI changes. * Ensure window has focus and is ready * Update window.ts * fix: webview screenshot capture prevent mini window to close * fix: Failed to take screenshot: Error: ENAMETOOLONG: name too long, open '/home/runner/work/TidGi-Desktop/TidGi-Desktop/userData-test/logs/screenshots/Agent workflow - Create notes- update embeddings- then search/2025-10-30T11-46-28-891Z-I type -在 wiki 工作区创建一个名为 AI Agent Guide 的笔记-内容是-智能体是一种可以执行任务的AI系统-它可以使用工具-搜索信息并与用户交互- in -chat input- element with selec-PASSED-page.png' * Update window.ts * feat: remove deprecated symlink subwiki approach * Update wiki.ts * fix: remove AI buggy bring window to front cause mini window test to fail * lint * Adjust wait time for draft saving in filesystemPlugin Increased wait time for file system plugin to save draft. * Adjust wait time for tiddler state stabilization Increased wait time to ensure tiddler state settles properly. * Refactor release workflow to simplify dependency installation Removed installation steps for x64 and arm64 dependencies, and adjusted the build process for plugins and native modules. * Enhance wait for IPC in filesystemPlugin feature Added a wait time to improve reliability of content update verification in CI.
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import { Then, When } from '@cucumber/cucumber';
|
|
import { backOff } from 'exponential-backoff';
|
|
import fs from 'fs-extra';
|
|
import path from 'path';
|
|
import type { IWorkspace } from '../../src/services/workspaces/interface';
|
|
import { settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths';
|
|
import type { ApplicationWorld } from './application';
|
|
|
|
// Backoff configuration for retries
|
|
const BACKOFF_OPTIONS = {
|
|
numOfAttempts: 10,
|
|
startingDelay: 200,
|
|
timeMultiple: 1.5,
|
|
};
|
|
|
|
/**
|
|
* Generic function to wait for a log marker to appear in wiki log files.
|
|
*/
|
|
async function waitForLogMarker(searchString: string, errorMessage: string, maxWaitMs = 10000, logFilePattern = 'wiki-'): Promise<void> {
|
|
const logPath = path.join(process.cwd(), 'userData-test', 'logs');
|
|
|
|
await backOff(
|
|
async () => {
|
|
try {
|
|
const files = await fs.readdir(logPath);
|
|
const logFiles = files.filter(f => f.startsWith(logFilePattern) && f.endsWith('.log'));
|
|
|
|
for (const file of logFiles) {
|
|
const content = await fs.readFile(path.join(logPath, file), 'utf-8');
|
|
if (content.includes(searchString)) {
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// Log directory might not exist yet, continue retrying
|
|
}
|
|
|
|
throw new Error('Log marker not found yet');
|
|
},
|
|
{
|
|
numOfAttempts: Math.ceil(maxWaitMs / 100),
|
|
startingDelay: 100,
|
|
timeMultiple: 1,
|
|
maxDelay: 100,
|
|
delayFirstAttempt: false,
|
|
jitter: 'none',
|
|
},
|
|
).catch(() => {
|
|
throw new Error(errorMessage);
|
|
});
|
|
}
|
|
|
|
When('I cleanup test wiki so it could create a new one on start', async function() {
|
|
if (fs.existsSync(wikiTestWikiPath)) fs.removeSync(wikiTestWikiPath);
|
|
|
|
/**
|
|
* Clean up wiki log files to prevent reading stale logs from previous scenarios.
|
|
* This is critical for tests that wait for log markers like [test-id-WATCH_FS_STABILIZED],
|
|
* as Node.js file system caching can cause tests to read old log content.
|
|
*/
|
|
const logDirectory = path.join(process.cwd(), 'userData-test', 'logs');
|
|
if (fs.existsSync(logDirectory)) {
|
|
const logFiles = fs.readdirSync(logDirectory).filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
|
|
for (const logFile of logFiles) {
|
|
fs.removeSync(path.join(logDirectory, logFile));
|
|
}
|
|
}
|
|
|
|
type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>;
|
|
if (!fs.existsSync(settingsPath)) return;
|
|
const settings = fs.readJsonSync(settingsPath) as SettingsFile;
|
|
const workspaces: Record<string, IWorkspace> = settings.workspaces ?? {};
|
|
const filtered: Record<string, IWorkspace> = {};
|
|
for (const id of Object.keys(workspaces)) {
|
|
const ws = workspaces[id];
|
|
const name = ws.name;
|
|
if (name === 'wiki' || id === 'wiki') continue;
|
|
filtered[id] = ws;
|
|
}
|
|
fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 });
|
|
});
|
|
|
|
/**
|
|
* Helper function to get directory tree structure
|
|
*/
|
|
async function getDirectoryTree(directory: string, prefix = '', maxDepth = 3, currentDepth = 0): Promise<string> {
|
|
if (currentDepth >= maxDepth || !(await fs.pathExists(directory))) {
|
|
return '';
|
|
}
|
|
|
|
let tree = '';
|
|
try {
|
|
const items = await fs.readdir(directory);
|
|
for (let index = 0; index < items.length; index++) {
|
|
const item = items[index];
|
|
const isLast = index === items.length - 1;
|
|
const itemPath = path.join(directory, item);
|
|
const connector = isLast ? '└── ' : '├── ';
|
|
|
|
try {
|
|
const stat = await fs.stat(itemPath);
|
|
tree += `${prefix}${connector}${item}${stat.isDirectory() ? '/' : ''}\n`;
|
|
|
|
if (stat.isDirectory()) {
|
|
const newPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
tree += await getDirectoryTree(itemPath, newPrefix, maxDepth, currentDepth + 1);
|
|
}
|
|
} catch {
|
|
tree += `${prefix}${connector}${item} [error reading]\n`;
|
|
}
|
|
}
|
|
} catch {
|
|
// Directory not readable
|
|
}
|
|
|
|
return tree;
|
|
}
|
|
|
|
/**
|
|
* Verify file exists in directory
|
|
*/
|
|
Then('file {string} should exist in {string}', async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) {
|
|
// Replace {tmpDir} with wiki test root (not wiki subfolder)
|
|
let directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// Resolve symlinks on all platforms to handle sub-wikis correctly
|
|
// On Linux, symlinks might point to the real path, so we need to follow them
|
|
if (await fs.pathExists(directoryPath)) {
|
|
try {
|
|
directoryPath = fs.realpathSync(directoryPath);
|
|
} catch {
|
|
// If realpathSync fails, continue with the original path
|
|
}
|
|
}
|
|
|
|
const filePath = path.join(directoryPath, fileName);
|
|
|
|
try {
|
|
await backOff(
|
|
async () => {
|
|
if (await fs.pathExists(filePath)) {
|
|
return;
|
|
}
|
|
throw new Error('File not found yet');
|
|
},
|
|
BACKOFF_OPTIONS,
|
|
);
|
|
} catch {
|
|
// Get 1 level up from actualPath
|
|
const oneLevelsUp = path.resolve(directoryPath, '..');
|
|
const tree = await getDirectoryTree(oneLevelsUp);
|
|
|
|
// Also read all .tid files in the actualPath directory
|
|
let tidFilesContent = '';
|
|
try {
|
|
if (await fs.pathExists(directoryPath)) {
|
|
const files = await fs.readdir(directoryPath);
|
|
const tidFiles = files.filter(f => f.endsWith('.tid'));
|
|
|
|
if (tidFiles.length > 0) {
|
|
tidFilesContent = '\n\n.tid files in directory:\n';
|
|
for (const tidFile of tidFiles) {
|
|
const tidPath = path.join(directoryPath, tidFile);
|
|
const content = await fs.readFile(tidPath, 'utf-8');
|
|
tidFilesContent += `\n=== ${tidFile} ===\n${content}\n`;
|
|
}
|
|
}
|
|
}
|
|
} catch (readError) {
|
|
tidFilesContent = `\n\nError reading .tid files: ${String(readError)}`;
|
|
}
|
|
|
|
throw new Error(
|
|
`File "${fileName}" not found in directory: ${directoryPath}\n\n` +
|
|
`Directory tree (1 level up from ${oneLevelsUp}):\n${tree}${tidFilesContent}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
Then('file {string} should not exist in {string}', { timeout: 15000 }, async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) {
|
|
// Replace {tmpDir} with wiki test root (not wiki subfolder)
|
|
let directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// Resolve symlinks on all platforms to handle sub-wikis correctly
|
|
if (await fs.pathExists(directoryPath)) {
|
|
try {
|
|
directoryPath = fs.realpathSync(directoryPath);
|
|
} catch {
|
|
// If realpathSync fails, continue with the original path
|
|
}
|
|
}
|
|
|
|
const filePath = path.join(directoryPath, fileName);
|
|
|
|
try {
|
|
await backOff(
|
|
async () => {
|
|
if (!(await fs.pathExists(filePath))) {
|
|
return;
|
|
}
|
|
throw new Error('File still exists');
|
|
},
|
|
BACKOFF_OPTIONS,
|
|
);
|
|
} catch {
|
|
throw new Error(
|
|
`File "${fileName}" should not exist but was found in directory: ${directoryPath}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Cleanup function for sub-wiki routing test
|
|
* Removes test workspaces created during the test
|
|
*/
|
|
async function clearSubWikiRoutingTestData() {
|
|
if (!(await fs.pathExists(settingsPath))) return;
|
|
|
|
type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>;
|
|
const settings = await fs.readJson(settingsPath) as SettingsFile;
|
|
const workspaces: Record<string, IWorkspace> = settings.workspaces ?? {};
|
|
const filtered: Record<string, IWorkspace> = {};
|
|
|
|
// Remove test workspaces (SubWiki, etc from sub-wiki routing tests)
|
|
for (const id of Object.keys(workspaces)) {
|
|
const ws = workspaces[id];
|
|
const name = ws.name;
|
|
// Keep workspaces that don't match test patterns
|
|
if (name !== 'SubWiki') {
|
|
filtered[id] = ws;
|
|
}
|
|
}
|
|
|
|
await fs.writeJson(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 });
|
|
|
|
// Remove test wiki folders from filesystem
|
|
const testFolders = ['SubWiki'];
|
|
for (const folder of testFolders) {
|
|
const wikiPath = path.join(wikiTestWikiPath, folder);
|
|
if (await fs.pathExists(wikiPath)) {
|
|
await fs.remove(wikiPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
Then('I wait for SSE and watch-fs to be ready', async function(this: ApplicationWorld) {
|
|
await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not become ready within timeout', 15000);
|
|
await waitForLogMarker('[test-id-SSE_READY]', 'SSE backend did not become ready within timeout', 15000);
|
|
});
|
|
|
|
Then('I wait for main wiki to restart after sub-wiki creation', async function(this: ApplicationWorld) {
|
|
await waitForLogMarker('[test-id-MAIN_WIKI_RESTARTED_AFTER_SUBWIKI]', 'Main wiki did not restart after sub-wiki creation within timeout', 20000, 'TidGi-');
|
|
// Also wait for SSE and watch-fs to be ready after restart
|
|
await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not become ready after restart within timeout', 15000);
|
|
await waitForLogMarker('[test-id-SSE_READY]', 'SSE backend did not become ready after restart within timeout', 15000);
|
|
});
|
|
|
|
Then('I wait for view to finish loading', async function(this: ApplicationWorld) {
|
|
await waitForLogMarker('[test-id-VIEW_LOADED]', 'Browser view did not finish loading within timeout', 10000, 'wiki-');
|
|
});
|
|
|
|
Then('I wait for tiddler {string} to be added by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) {
|
|
await waitForLogMarker(
|
|
`[test-id-WATCH_FS_TIDDLER_ADDED] ${tiddlerTitle}`,
|
|
`Tiddler "${tiddlerTitle}" was not added within timeout`,
|
|
);
|
|
});
|
|
|
|
Then('I wait for tiddler {string} to be updated by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) {
|
|
await waitForLogMarker(
|
|
`[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`,
|
|
`Tiddler "${tiddlerTitle}" was not updated within timeout`,
|
|
);
|
|
});
|
|
|
|
Then('I wait for tiddler {string} to be deleted by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) {
|
|
await waitForLogMarker(
|
|
`[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`,
|
|
`Tiddler "${tiddlerTitle}" was not deleted within timeout`,
|
|
);
|
|
});
|
|
|
|
// File manipulation step definitions
|
|
|
|
When('I create file {string} with content:', async function(this: ApplicationWorld, filePath: string, content: string) {
|
|
// Replace {tmpDir} placeholder with actual temp directory
|
|
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// Ensure directory exists
|
|
await fs.ensureDir(path.dirname(actualPath));
|
|
|
|
// Write the file with the provided content
|
|
await fs.writeFile(actualPath, content, 'utf-8');
|
|
});
|
|
|
|
When('I modify file {string} to contain {string}', async function(this: ApplicationWorld, filePath: string, content: string) {
|
|
// Replace {tmpDir} placeholder with actual temp directory
|
|
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// Read the existing file
|
|
let fileContent = await fs.readFile(actualPath, 'utf-8');
|
|
|
|
// TiddlyWiki .tid files have a format: headers followed by blank line and text
|
|
// We need to preserve headers and only modify the text part
|
|
// Split by both \n and \r\n to handle different line endings
|
|
const lines = fileContent.split(/\r?\n/);
|
|
|
|
const blankLineIndex = lines.findIndex(line => line.trim() === '');
|
|
|
|
if (blankLineIndex >= 0) {
|
|
// File has headers and content separated by blank line
|
|
// Keep headers, replace text after blank line
|
|
const headers = lines.slice(0, blankLineIndex + 1);
|
|
|
|
// Note: We intentionally do NOT update the modified field here
|
|
// This simulates a real user editing the file in an external editor,
|
|
// where the modified field would not be automatically updated
|
|
// The echo prevention mechanism should detect this as a real external change
|
|
// because the content changed but the modified timestamp stayed the same
|
|
|
|
fileContent = [...headers, content].join('\n');
|
|
} else {
|
|
// File has only headers, no content yet (no blank line separator)
|
|
// We need to add the blank line separator and the content
|
|
// Again, we don't modify the modified field
|
|
fileContent = [...lines, '', content].join('\n');
|
|
}
|
|
|
|
// Write the modified content back
|
|
await fs.writeFile(actualPath, fileContent, 'utf-8');
|
|
});
|
|
|
|
When('I modify file {string} to contain:', async function(this: ApplicationWorld, filePath: string, content: string) {
|
|
// Replace {tmpDir} placeholder with actual temp directory
|
|
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// For multi-line content with headers, just write the content directly
|
|
// (assumes the content includes all headers and structure)
|
|
await fs.writeFile(actualPath, content, 'utf-8');
|
|
});
|
|
|
|
When('I delete file {string}', async function(this: ApplicationWorld, filePath: string) {
|
|
// Replace {tmpDir} placeholder with actual temp directory
|
|
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// Delete the file
|
|
await fs.remove(actualPath);
|
|
});
|
|
|
|
When('I rename file {string} to {string}', async function(this: ApplicationWorld, oldPath: string, newPath: string) {
|
|
// Replace {tmpDir} placeholder with actual temp directory
|
|
const actualOldPath = oldPath.replace('{tmpDir}', wikiTestRootPath);
|
|
const actualNewPath = newPath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// Ensure the target directory exists
|
|
await fs.ensureDir(path.dirname(actualNewPath));
|
|
|
|
// Rename/move the file
|
|
await fs.rename(actualOldPath, actualNewPath);
|
|
});
|
|
|
|
When('I modify file {string} to add field {string}', async function(this: ApplicationWorld, filePath: string, fieldLine: string) {
|
|
// Replace {tmpDir} placeholder with actual temp directory
|
|
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
|
|
|
|
// Read the existing file
|
|
const fileContent = await fs.readFile(actualPath, 'utf-8');
|
|
|
|
// TiddlyWiki .tid files have headers followed by a blank line and text
|
|
// We need to add the field to the headers section
|
|
const lines = fileContent.split('\n');
|
|
const blankLineIndex = lines.findIndex(line => line.trim() === '');
|
|
|
|
if (blankLineIndex >= 0) {
|
|
// Insert the new field before the blank line
|
|
lines.splice(blankLineIndex, 0, fieldLine);
|
|
} else {
|
|
// No blank line found, add to the beginning
|
|
lines.unshift(fieldLine);
|
|
}
|
|
|
|
// Write the modified content back
|
|
await fs.writeFile(actualPath, lines.join('\n'), 'utf-8');
|
|
});
|
|
|
|
export { clearSubWikiRoutingTestData };
|