mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-06 02:30:47 -08:00
Feat/allow watch fs change on git sync
* 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.
This commit is contained in:
parent
7473612cec
commit
7f5e1aa0cc
54 changed files with 1787 additions and 778 deletions
|
|
@ -12,7 +12,7 @@ Feature: Filesystem Plugin
|
|||
Then the browser view should be loaded and visible
|
||||
And I wait for SSE and watch-fs to be ready
|
||||
|
||||
@subwiki
|
||||
@file-watching @subwiki
|
||||
Scenario: Tiddler with tag saves to sub-wiki folder
|
||||
# Create sub-workspace linked to the default wiki
|
||||
When I click on an "add workspace button" element with selector "#add-workspace-button"
|
||||
|
|
@ -27,16 +27,64 @@ Feature: Filesystem Plugin
|
|||
And I click on a "create sub workspace button" element with selector "button.MuiButton-colorSecondary"
|
||||
And I switch to "main" window
|
||||
Then I should see a "SubWiki workspace" element with selector "div[data-testid^='workspace-']:has-text('SubWiki')"
|
||||
# Switch to default wiki and create tiddler with tag
|
||||
When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
|
||||
And I click on "add tiddler button" element in browser view with selector "button[aria-label='添加条目']"
|
||||
# Wait for main wiki to restart after sub-wiki creation
|
||||
Then I wait for main wiki to restart after sub-wiki creation
|
||||
Then I wait for view to finish loading
|
||||
# Click SubWiki workspace again to ensure TestTag tiddler is displayed
|
||||
And I wait for 1 seconds
|
||||
When I click on a "SubWiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('SubWiki')"
|
||||
And I wait for 1 seconds
|
||||
# Verify TestTag tiddler is visible
|
||||
And I should see "TestTag" in the browser view content
|
||||
# Create tiddler with tag to test file system plugin
|
||||
And I click on "add tiddler button" element in browser view with selector "button:has(.tc-image-new-button)"
|
||||
# Focus on title input, clear it, and type new title in the draft tiddler
|
||||
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 type "Test Tiddler Title" in "title input" element in browser view with selector "input.tc-titlebar.tc-edit-texteditor"
|
||||
And I type "TestTag" in "tag input" element in browser view with selector "input.tc-edit-texteditor.tc-popup-handle"
|
||||
And I press "Enter" in browser view
|
||||
And I click on "confirm button" element in browser view with selector "button[aria-label='确定对此条目的更改']"
|
||||
# Verify the tiddler file exists in sub-wiki folder (not in tiddlers subfolder)
|
||||
Then file "Test Tiddler Title.tid" should exist in "{tmpDir}/SubWiki"
|
||||
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 "TestTiddlerTitle" in "title input" element in browser view with selector "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor"
|
||||
# Wait for tiddler state to settle, otherwise it still shows 3 chars (新条目) for a while
|
||||
And I wait for 2 seconds
|
||||
Then I should see "16 chars" in the browser view content
|
||||
# Input tag by typing in the tag input field - use precise selector to target the tag input specifically
|
||||
And I click on "tag input" element in browser view with selector "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']"
|
||||
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 wait for 0.2 seconds
|
||||
And I type "TestTag" in "tag input" element in browser view with selector "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']"
|
||||
# Click the add tag button to confirm the tag (not just typing)
|
||||
And I wait for 0.2 seconds
|
||||
And I click on "add tag button" element in browser view with selector "div[data-tiddler-title^='Draft of'] span.tc-add-tag-button button"
|
||||
# Wait for file system plugin to save the draft tiddler to SubWiki folder, Even 3 second will randomly failed in next step.
|
||||
And I wait for 4.5 seconds
|
||||
# Verify the DRAFT tiddler has been routed to sub-wiki immediately after adding the tag
|
||||
Then file "Draft of '新条目'.tid" should exist in "{tmpDir}/SubWiki"
|
||||
# Verify the draft file is NOT in main wiki tiddlers folder (it should have been moved to SubWiki)
|
||||
Then file "Draft of '新条目'.tid" should not exist in "{tmpDir}/wiki/tiddlers"
|
||||
# Click confirm button 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 1 seconds
|
||||
# Verify the final tiddler file exists in sub-wiki folder after save
|
||||
# After confirming the draft, it should be saved as TestTiddlerTitle.tid in SubWiki
|
||||
Then file "TestTiddlerTitle.tid" should exist in "{tmpDir}/SubWiki"
|
||||
# Test SSE is still working after SubWiki creation - modify a main wiki tiddler
|
||||
When I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to contain "Main wiki content modified after SubWiki creation"
|
||||
Then I wait for tiddler "Index" to be updated by watch-fs
|
||||
# Confirm Index always open
|
||||
Then I should see a "Index tiddler" element in browser view with selector "div[data-tiddler-title='Index']"
|
||||
Then I should see "Main wiki content modified after SubWiki creation" in the browser view content
|
||||
# Test modification in sub-workspace via symlink
|
||||
# Modify the tiddler file externally - need to preserve .tid format with metadata
|
||||
When I modify file "{tmpDir}/SubWiki/TestTiddlerTitle.tid" to contain "Content modified in SubWiki symlink"
|
||||
# Wait for watch-fs to detect the change
|
||||
Then I wait for tiddler "TestTiddlerTitle" to be updated by watch-fs
|
||||
And I wait for 2 seconds
|
||||
# Verify the modified content appears in the wiki
|
||||
Then I should see "Content modified in SubWiki symlink" in the browser view content
|
||||
|
||||
@file-watching
|
||||
Scenario: External file creation syncs to wiki
|
||||
|
|
@ -53,7 +101,8 @@ Feature: Filesystem Plugin
|
|||
Then I wait for tiddler "WatchTestTiddler" to be added by watch-fs
|
||||
# Open sidebar "最近" tab to see the timeline
|
||||
And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')"
|
||||
And I wait for 0.5 seconds
|
||||
# wait for tw animation, sidebar need time to show
|
||||
And I wait for 1 seconds
|
||||
# Click on the tiddler link in timeline to open it
|
||||
And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('WatchTestTiddler')"
|
||||
# Verify the tiddler content is displayed
|
||||
|
|
@ -75,11 +124,13 @@ Feature: Filesystem Plugin
|
|||
And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')"
|
||||
And I wait for 0.5 seconds
|
||||
And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TestTiddler')"
|
||||
And I wait for 0.5 seconds
|
||||
Then I should see "Original content" in the browser view content
|
||||
# Modify the file externally
|
||||
When I modify file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" to contain "Modified content from external editor"
|
||||
Then I wait for tiddler "TestTiddler" to be updated by watch-fs
|
||||
# Verify the wiki shows updated content (should auto-refresh)
|
||||
# Verify the wiki shows updated content (should auto-refresh), need to wait for IPC, it is slow on CI and will randomly failed
|
||||
And I wait for 2 seconds
|
||||
Then I should see "Modified content from external editor" in the browser view content
|
||||
# Now delete the file externally
|
||||
When I delete file "{tmpDir}/wiki/tiddlers/TestTiddler.tid"
|
||||
|
|
@ -89,6 +140,15 @@ Feature: Filesystem Plugin
|
|||
# The timeline should not have a clickable link to TestTiddler anymore
|
||||
Then I should not see a "TestTiddler timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TestTiddler')"
|
||||
|
||||
@file-watching
|
||||
Scenario: Deleting open tiddler file shows missing tiddler message
|
||||
# Delete the Index.tid file while Index tiddler is open (it's open by default)
|
||||
When I delete file "{tmpDir}/wiki/tiddlers/Index.tid"
|
||||
Then I wait for tiddler "Index" to be deleted by watch-fs
|
||||
And I wait for 0.5 seconds
|
||||
# Verify the missing tiddler message appears in the tiddler frame
|
||||
Then I should see "佚失条目 \"Index\"" in the browser view DOM
|
||||
|
||||
@file-watching
|
||||
Scenario: External file rename syncs to wiki
|
||||
# Create initial file
|
||||
|
|
@ -121,7 +181,7 @@ Feature: Filesystem Plugin
|
|||
Then I wait for tiddler "NewName" to be updated by watch-fs
|
||||
# Navigate to timeline to verify changes
|
||||
And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')"
|
||||
And I wait for 0.5 seconds
|
||||
And I wait for 1 seconds
|
||||
# Verify new name appears
|
||||
And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('NewName')"
|
||||
Then I should see "Content before rename" in the browser view content
|
||||
|
|
|
|||
|
|
@ -292,11 +292,11 @@ Given('I add test ai settings', function() {
|
|||
fs.writeJsonSync(settingsPath, { ...existing, aiSettings: newAi } as ISettingFile, { spaces: 2 });
|
||||
});
|
||||
|
||||
function clearAISettings() {
|
||||
if (!fs.existsSync(settingsPath)) return;
|
||||
const parsed = fs.readJsonSync(settingsPath) as ISettingFile;
|
||||
async function clearAISettings() {
|
||||
if (!(await fs.pathExists(settingsPath))) return;
|
||||
const parsed = await fs.readJson(settingsPath) as ISettingFile;
|
||||
const cleaned = omit(parsed, ['aiSettings']);
|
||||
fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 });
|
||||
await fs.writeJson(settingsPath, cleaned, { spaces: 2 });
|
||||
}
|
||||
|
||||
export { clearAISettings };
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { MockOAuthServer } from '../supports/mockOAuthServer';
|
|||
import { MockOpenAIServer } from '../supports/mockOpenAI';
|
||||
import { makeSlugPath, screenshotsDirectory } from '../supports/paths';
|
||||
import { getPackedAppPath } from '../supports/paths';
|
||||
import { captureScreenshot } from '../supports/webContentsViewHelper';
|
||||
|
||||
// Backoff configuration for retries
|
||||
const BACKOFF_OPTIONS = {
|
||||
|
|
@ -199,6 +200,13 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result })
|
|||
// if (!process.env.CI) return;
|
||||
|
||||
try {
|
||||
const stepText = pickleStep.text;
|
||||
|
||||
// Skip screenshots for wait steps to avoid too many screenshots
|
||||
if (stepText.match(/^I wait for \d+(\.\d+)? seconds?$/i)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer an existing currentWindow if it's still open
|
||||
let pageToUse: Page | undefined;
|
||||
|
||||
|
|
@ -211,15 +219,15 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result })
|
|||
const openPages = this.app.windows().filter(p => !p.isClosed());
|
||||
if (openPages.length > 0) {
|
||||
pageToUse = openPages[0];
|
||||
this.currentWindow = pageToUse;
|
||||
}
|
||||
}
|
||||
|
||||
const scenarioName = pickle.name;
|
||||
const cleanScenarioName = makeSlugPath(scenarioName);
|
||||
// Limit scenario slug to avoid extremely long directory names
|
||||
const cleanScenarioName = makeSlugPath(scenarioName, 60);
|
||||
|
||||
const stepText = pickleStep.text;
|
||||
const cleanStepText = makeSlugPath(stepText, 120);
|
||||
// Limit step text slug to avoid excessively long filenames which can trigger ENAMETOOLONG
|
||||
const cleanStepText = makeSlugPath(stepText, 80);
|
||||
const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status';
|
||||
|
||||
const featureDirectory = path.resolve(screenshotsDirectory, cleanScenarioName);
|
||||
|
|
@ -239,10 +247,17 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result })
|
|||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const screenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}.jpg`);
|
||||
|
||||
// Use conservative screenshot options for CI
|
||||
await pageToUse.screenshot({ path: screenshotPath, fullPage: true, type: 'jpeg', quality: 10 });
|
||||
// Try to capture both WebContentsView and Page screenshots
|
||||
let webViewCaptured = false;
|
||||
if (this.app) {
|
||||
const webViewScreenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}-webview.png`);
|
||||
webViewCaptured = await captureScreenshot(this.app, webViewScreenshotPath);
|
||||
}
|
||||
|
||||
// Always capture page screenshot (UI chrome/window)
|
||||
const pageScreenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}${webViewCaptured ? '-page' : ''}.png`);
|
||||
await pageToUse.screenshot({ path: pageScreenshotPath, fullPage: true, type: 'png' });
|
||||
} catch (screenshotError) {
|
||||
console.warn('Failed to take screenshot:', screenshotError);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { ApplicationWorld } from './application';
|
|||
|
||||
// Backoff configuration for retries
|
||||
const BACKOFF_OPTIONS = {
|
||||
numOfAttempts: 8,
|
||||
numOfAttempts: 10,
|
||||
startingDelay: 100,
|
||||
timeMultiple: 2,
|
||||
};
|
||||
|
|
@ -60,7 +60,7 @@ Then('I should see {string} in the browser view DOM', async function(this: Appli
|
|||
});
|
||||
});
|
||||
|
||||
Then('the browser view should be loaded and visible', async function(this: ApplicationWorld) {
|
||||
Then('the browser view should be loaded and visible', { timeout: 15000 }, async function(this: ApplicationWorld) {
|
||||
if (!this.app) {
|
||||
throw new Error('Application not launched');
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@ Then('the browser view should be loaded and visible', async function(this: Appli
|
|||
throw new Error('Browser view not loaded');
|
||||
}
|
||||
},
|
||||
BACKOFF_OPTIONS,
|
||||
{ ...BACKOFF_OPTIONS, numOfAttempts: 15 },
|
||||
).catch(() => {
|
||||
throw new Error('Browser view is not loaded or visible after multiple attempts');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,22 +6,22 @@ import { ApplicationWorld } from './application';
|
|||
import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow';
|
||||
import { clearSubWikiRoutingTestData } from './wiki';
|
||||
|
||||
Before(function(this: ApplicationWorld, { pickle }) {
|
||||
Before(async function(this: ApplicationWorld, { pickle }) {
|
||||
// Create necessary directories under userData-test/logs to match appPaths in dev/test
|
||||
if (!fs.existsSync(logsDirectory)) {
|
||||
fs.mkdirSync(logsDirectory, { recursive: true });
|
||||
if (!(await fs.pathExists(logsDirectory))) {
|
||||
await fs.ensureDir(logsDirectory);
|
||||
}
|
||||
|
||||
// Create screenshots subdirectory in logs
|
||||
if (!fs.existsSync(screenshotsDirectory)) {
|
||||
fs.mkdirSync(screenshotsDirectory, { recursive: true });
|
||||
if (!(await fs.pathExists(screenshotsDirectory))) {
|
||||
await fs.ensureDir(screenshotsDirectory);
|
||||
}
|
||||
|
||||
if (pickle.tags.some((tag) => tag.name === '@ai-setting')) {
|
||||
clearAISettings();
|
||||
await clearAISettings();
|
||||
}
|
||||
if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) {
|
||||
clearTidgiMiniWindowSettings();
|
||||
await clearTidgiMiniWindowSettings();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -30,15 +30,17 @@ After(async function(this: ApplicationWorld, { pickle }) {
|
|||
try {
|
||||
// Close all windows including tidgi mini window before closing the app, otherwise it might hang, and refused to exit until ctrl+C
|
||||
const allWindows = this.app.windows();
|
||||
for (const window of allWindows) {
|
||||
try {
|
||||
if (!window.isClosed()) {
|
||||
await window.close();
|
||||
await Promise.all(
|
||||
allWindows.map(async (window) => {
|
||||
try {
|
||||
if (!window.isClosed()) {
|
||||
await window.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error closing window:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error closing window:', error);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
await this.app.close();
|
||||
} catch (error) {
|
||||
console.error('Error during cleanup:', error);
|
||||
|
|
@ -48,12 +50,34 @@ After(async function(this: ApplicationWorld, { pickle }) {
|
|||
this.currentWindow = undefined;
|
||||
}
|
||||
if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) {
|
||||
clearTidgiMiniWindowSettings();
|
||||
await clearTidgiMiniWindowSettings();
|
||||
}
|
||||
if (pickle.tags.some((tag) => tag.name === '@ai-setting')) {
|
||||
clearAISettings();
|
||||
await clearAISettings();
|
||||
}
|
||||
if (pickle.tags.some((tag) => tag.name === '@subwiki')) {
|
||||
clearSubWikiRoutingTestData();
|
||||
await clearSubWikiRoutingTestData();
|
||||
}
|
||||
|
||||
// Separate logs by test scenario for easier debugging
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const wikiLogFile = `${logsDirectory}/wiki-${today}.log`;
|
||||
const tidgiLogFile = `${logsDirectory}/TidGi-${today}.log`;
|
||||
|
||||
// Create a sanitized scenario name for the log files
|
||||
const scenarioName = pickle.name.replace(/[^a-z0-9]/gi, '_').substring(0, 50);
|
||||
|
||||
if (await fs.pathExists(wikiLogFile)) {
|
||||
const targetWikiLog = `${logsDirectory}/${scenarioName}_wiki.log`;
|
||||
await fs.move(wikiLogFile, targetWikiLog, { overwrite: true });
|
||||
}
|
||||
|
||||
if (await fs.pathExists(tidgiLogFile)) {
|
||||
const targetTidgiLog = `${logsDirectory}/${scenarioName}_TidGi.log`;
|
||||
await fs.move(tidgiLogFile, targetTidgiLog, { overwrite: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving log files:', error);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ Given('I configure tidgi mini window with shortcut', async function() {
|
|||
});
|
||||
|
||||
// Cleanup function to be called after tidgi mini window tests (after app closes)
|
||||
function clearTidgiMiniWindowSettings() {
|
||||
if (!fs.existsSync(settingsPath)) return;
|
||||
const parsed = fs.readJsonSync(settingsPath) as ISettingFile;
|
||||
async function clearTidgiMiniWindowSettings() {
|
||||
if (!(await fs.pathExists(settingsPath))) return;
|
||||
const parsed = await fs.readJson(settingsPath) as ISettingFile;
|
||||
// Remove tidgi mini window-related preferences to avoid affecting other tests
|
||||
const cleanedPreferences = omit(parsed.preferences || {}, [
|
||||
'tidgiMiniWindow',
|
||||
|
|
@ -68,7 +68,7 @@ function clearTidgiMiniWindowSettings() {
|
|||
}
|
||||
|
||||
const cleaned = { ...parsed, preferences: cleanedPreferences, workspaces };
|
||||
fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 });
|
||||
await fs.writeJson(settingsPath, cleaned, { spaces: 2 });
|
||||
}
|
||||
|
||||
export { clearTidgiMiniWindowSettings };
|
||||
|
|
|
|||
|
|
@ -1,134 +1,53 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
* Wait for both SSE and watch-fs to be ready and stabilized.
|
||||
* This combines the checks for test-id-SSE_READY and test-id-WATCH_FS_STABILIZED markers.
|
||||
*/
|
||||
async function waitForSSEAndWatchFsReady(maxWaitMs = 15000): Promise<void> {
|
||||
const logPath = path.join(process.cwd(), 'userData-test', 'logs');
|
||||
const startTime = Date.now();
|
||||
let sseReady = false;
|
||||
let watchFsStabilized = false;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
const files = await fs.readdir(logPath);
|
||||
const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
|
||||
|
||||
for (const file of wikiLogFiles) {
|
||||
const content = await fs.readFile(path.join(logPath, file), 'utf-8');
|
||||
if (content.includes('[test-id-SSE_READY]')) {
|
||||
sseReady = true;
|
||||
}
|
||||
if (content.includes('[test-id-WATCH_FS_STABILIZED]')) {
|
||||
watchFsStabilized = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sseReady && watchFsStabilized) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Log directory might not exist yet, continue waiting
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const missingServices = [];
|
||||
if (!sseReady) missingServices.push('SSE');
|
||||
if (!watchFsStabilized) missingServices.push('watch-fs');
|
||||
throw new Error(`${missingServices.join(' and ')} did not become ready within timeout`);
|
||||
}
|
||||
// Backoff configuration for retries
|
||||
const BACKOFF_OPTIONS = {
|
||||
numOfAttempts: 10,
|
||||
startingDelay: 200,
|
||||
timeMultiple: 1.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for a tiddler to be added by watch-fs.
|
||||
* Generic function to wait for a log marker to appear in wiki log files.
|
||||
*/
|
||||
async function waitForTiddlerAdded(tiddlerTitle: string, maxWaitMs = 10000): Promise<void> {
|
||||
async function waitForLogMarker(searchString: string, errorMessage: string, maxWaitMs = 10000, logFilePattern = 'wiki-'): Promise<void> {
|
||||
const logPath = path.join(process.cwd(), 'userData-test', 'logs');
|
||||
const startTime = Date.now();
|
||||
const searchString = `[test-id-WATCH_FS_TIDDLER_ADDED] ${tiddlerTitle}`;
|
||||
const files = await fs.readdir(logPath);
|
||||
const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
for (const file of wikiLogFiles) {
|
||||
const content = await fs.readFile(path.join(logPath, file), 'utf-8');
|
||||
if (content.includes(searchString)) {
|
||||
return;
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
// Log directory might not exist yet, continue waiting
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error(`Tiddler "${tiddlerTitle}" was not added within timeout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a tiddler to be updated by watch-fs.
|
||||
*/
|
||||
async function waitForTiddlerUpdated(tiddlerTitle: string, maxWaitMs = 10000): Promise<void> {
|
||||
const logPath = path.join(process.cwd(), 'userData-test', 'logs');
|
||||
const startTime = Date.now();
|
||||
const searchString = `[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`;
|
||||
const files = await fs.readdir(logPath);
|
||||
const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
for (const file of wikiLogFiles) {
|
||||
const content = await fs.readFile(path.join(logPath, file), 'utf-8');
|
||||
if (content.includes(searchString)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Log directory might not exist yet, continue waiting
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error(`Tiddler "${tiddlerTitle}" was not updated within timeout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a tiddler to be deleted by watch-fs.
|
||||
*/
|
||||
async function waitForTiddlerDeleted(tiddlerTitle: string, maxWaitMs = 10000): Promise<void> {
|
||||
const logPath = path.join(process.cwd(), 'userData-test', 'logs');
|
||||
const startTime = Date.now();
|
||||
const searchString = `[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`;
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
try {
|
||||
const files = await fs.readdir(logPath);
|
||||
const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
|
||||
|
||||
for (const file of wikiLogFiles) {
|
||||
const content = await fs.readFile(path.join(logPath, file), 'utf-8');
|
||||
if (content.includes(searchString)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Log directory might not exist yet, continue waiting
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error(`Tiddler "${tiddlerTitle}" was not deleted within timeout`);
|
||||
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() {
|
||||
|
|
@ -162,24 +81,131 @@ When('I cleanup test wiki so it could create a new one on start', async function
|
|||
});
|
||||
|
||||
/**
|
||||
* Verify file exists in directory
|
||||
* Helper function to get directory tree structure
|
||||
*/
|
||||
Then('file {string} should exist in {string}', { timeout: 15000 }, async function(this: ApplicationWorld, fileName: string, directoryPath: string) {
|
||||
// Replace {tmpDir} with wiki test root (not wiki subfolder)
|
||||
const actualPath = directoryPath.replace('{tmpDir}', wikiTestRootPath);
|
||||
const filePath = path.join(actualPath, fileName);
|
||||
|
||||
let exists = false;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
if (await fs.pathExists(filePath)) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
async function getDirectoryTree(directory: string, prefix = '', maxDepth = 3, currentDepth = 0): Promise<string> {
|
||||
if (currentDepth >= maxDepth || !(await fs.pathExists(directory))) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(`File "${fileName}" not found in directory: ${actualPath}`);
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -187,11 +213,11 @@ Then('file {string} should exist in {string}', { timeout: 15000 }, async functio
|
|||
* Cleanup function for sub-wiki routing test
|
||||
* Removes test workspaces created during the test
|
||||
*/
|
||||
function clearSubWikiRoutingTestData() {
|
||||
if (!fs.existsSync(settingsPath)) return;
|
||||
async function clearSubWikiRoutingTestData() {
|
||||
if (!(await fs.pathExists(settingsPath))) return;
|
||||
|
||||
type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>;
|
||||
const settings = fs.readJsonSync(settingsPath) as SettingsFile;
|
||||
const settings = await fs.readJson(settingsPath) as SettingsFile;
|
||||
const workspaces: Record<string, IWorkspace> = settings.workspaces ?? {};
|
||||
const filtered: Record<string, IWorkspace> = {};
|
||||
|
||||
|
|
@ -205,48 +231,53 @@ function clearSubWikiRoutingTestData() {
|
|||
}
|
||||
}
|
||||
|
||||
fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 });
|
||||
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 (fs.existsSync(wikiPath)) {
|
||||
fs.removeSync(wikiPath);
|
||||
if (await fs.pathExists(wikiPath)) {
|
||||
await fs.remove(wikiPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Then('I wait for SSE and watch-fs to be ready', { timeout: 20000 }, async function(this: ApplicationWorld) {
|
||||
try {
|
||||
await waitForSSEAndWatchFsReady();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to wait for SSE and watch-fs: ${(error as Error).message}`);
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
await waitForTiddlerAdded(tiddlerTitle);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be added: ${(error as Error).message}`);
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
await waitForTiddlerUpdated(tiddlerTitle);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be updated: ${(error as Error).message}`);
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
await waitForTiddlerDeleted(tiddlerTitle);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be deleted: ${(error as Error).message}`);
|
||||
}
|
||||
await waitForLogMarker(
|
||||
`[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`,
|
||||
`Tiddler "${tiddlerTitle}" was not deleted within timeout`,
|
||||
);
|
||||
});
|
||||
|
||||
// File manipulation step definitions
|
||||
|
|
@ -271,16 +302,28 @@ When('I modify file {string} to contain {string}', async function(this: Applicat
|
|||
|
||||
// TiddlyWiki .tid files have a format: headers followed by blank line and text
|
||||
// We need to preserve headers and only modify the text part
|
||||
const lines = fileContent.split('\n');
|
||||
// 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 {
|
||||
// No headers found, just use content
|
||||
fileContent = content;
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { When } from '@cucumber/cucumber';
|
||||
import { WebContentsView } from 'electron';
|
||||
import type { ElectronApplication } from 'playwright';
|
||||
import type { ApplicationWorld } from './application';
|
||||
import { checkWindowDimension, checkWindowName } from './application';
|
||||
import { WebContentsView } from 'electron';
|
||||
|
||||
// Helper function to get browser view info from Electron window
|
||||
async function getBrowserViewInfo(
|
||||
|
|
@ -53,7 +53,7 @@ When('I confirm the {string} window exists', async function(this: ApplicationWor
|
|||
|
||||
const success = await this.waitForWindowCondition(
|
||||
windowType,
|
||||
(window) => window !== undefined && !window.isClosed(),
|
||||
(window) => window !== undefined,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
|
|
@ -83,7 +83,7 @@ When('I confirm the {string} window not visible', async function(this: Applicati
|
|||
|
||||
const success = await this.waitForWindowCondition(
|
||||
windowType,
|
||||
(window, isVisible) => window !== undefined && !window.isClosed() && !isVisible,
|
||||
(window, isVisible) => window !== undefined && !isVisible,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const unsafeChars = /[^\p{L}\p{N}\s\-_()]/gu;
|
|||
const collapseDashes = /-+/g;
|
||||
const collapseSpaces = /\s+/g;
|
||||
export const makeSlugPath = (input: string | undefined, maxLength = 120) => {
|
||||
let s = String(input || 'unknown').normalize('NFKC');
|
||||
let s = (input || 'unknown').normalize('NFKC');
|
||||
// remove dots explicitly
|
||||
s = s.replace(/\./g, '');
|
||||
// replace unsafe characters with dashes
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
import { WebContentsView } from 'electron';
|
||||
import type { ElectronApplication } from 'playwright';
|
||||
|
||||
/**
|
||||
* Get the first WebContentsView from current window
|
||||
* Since we only have one WebContentsView per window in main window, we don't need to loop through all windows
|
||||
*/
|
||||
async function getFirstWebContentsView(app: ElectronApplication) {
|
||||
return await app.evaluate(async ({ BrowserWindow }) => {
|
||||
const allWindows = BrowserWindow.getAllWindows();
|
||||
const mainWindow = allWindows.find(w => !w.isDestroyed() && w.webContents?.getType() === 'window');
|
||||
|
||||
if (!mainWindow?.contentView || !('children' in mainWindow.contentView)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = (mainWindow.contentView as WebContentsView).children as WebContentsView[];
|
||||
if (!Array.isArray(children) || children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children[0]?.webContents?.id ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute JavaScript in the browser view
|
||||
*/
|
||||
async function executeInBrowserView<T>(
|
||||
app: ElectronApplication,
|
||||
script: string,
|
||||
): Promise<T> {
|
||||
const webContentsId = await getFirstWebContentsView(app);
|
||||
|
||||
if (!webContentsId) {
|
||||
throw new Error('No browser view found');
|
||||
}
|
||||
|
||||
return await app.evaluate(
|
||||
async ({ webContents }, [id, scriptContent]) => {
|
||||
const targetWebContents = webContents.fromId(id as number);
|
||||
if (!targetWebContents) {
|
||||
throw new Error('WebContents not found');
|
||||
}
|
||||
const result: T = await targetWebContents.executeJavaScript(scriptContent as string, true) as T;
|
||||
return result;
|
||||
},
|
||||
[webContentsId, script],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text content from WebContentsView
|
||||
*/
|
||||
export async function getTextContent(app: ElectronApplication): Promise<string | null> {
|
||||
try {
|
||||
return await executeInBrowserView<string>(
|
||||
app,
|
||||
'document.body.textContent || document.body.innerText || ""',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DOM content from WebContentsView
|
||||
*/
|
||||
export async function getDOMContent(app: ElectronApplication): Promise<string | null> {
|
||||
try {
|
||||
return await executeInBrowserView<string>(
|
||||
app,
|
||||
'document.documentElement.outerHTML || ""',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebContentsView exists and is loaded
|
||||
*/
|
||||
export async function isLoaded(app: ElectronApplication): Promise<boolean> {
|
||||
const webContentsId = await getFirstWebContentsView(app);
|
||||
return webContentsId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click element containing specific text in browser view
|
||||
*/
|
||||
export async function clickElementWithText(
|
||||
app: ElectronApplication,
|
||||
selector: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const text = ${JSON.stringify(text)};
|
||||
const elements = document.querySelectorAll(selector);
|
||||
let found = null;
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const elem = elements[i];
|
||||
const elemText = elem.textContent || elem.innerText || '';
|
||||
if (elemText.trim() === text.trim() || elemText.includes(text)) {
|
||||
found = elem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Element with text "' + text + '" not found in selector: ' + selector);
|
||||
}
|
||||
|
||||
found.click();
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
await executeInBrowserView(app, script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click element in browser view
|
||||
*/
|
||||
export async function clickElement(app: ElectronApplication, selector: string): Promise<void> {
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const elem = document.querySelector(selector);
|
||||
|
||||
if (!elem) {
|
||||
throw new Error('Element not found: ' + selector);
|
||||
}
|
||||
|
||||
elem.click();
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
await executeInBrowserView(app, script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type text in element in browser view
|
||||
*/
|
||||
export async function typeText(app: ElectronApplication, selector: string, text: string): Promise<void> {
|
||||
const escapedSelector = selector.replace(/'/g, "\\'");
|
||||
const escapedText = text.replace(/'/g, "\\'").replace(/\n/g, '\\n');
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = '${escapedSelector}';
|
||||
const text = '${escapedText}';
|
||||
const elem = document.querySelector(selector);
|
||||
|
||||
if (!elem) {
|
||||
throw new Error('Element not found: ' + selector);
|
||||
}
|
||||
|
||||
elem.focus();
|
||||
if (elem.tagName === 'TEXTAREA' || elem.tagName === 'INPUT') {
|
||||
elem.value = text;
|
||||
} else {
|
||||
elem.textContent = text;
|
||||
}
|
||||
|
||||
elem.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
elem.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
await executeInBrowserView(app, script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press key in browser view
|
||||
*/
|
||||
export async function pressKey(app: ElectronApplication, key: string): Promise<void> {
|
||||
const escapedKey = key.replace(/'/g, "\\'");
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
const key = '${escapedKey}';
|
||||
|
||||
const keydownEvent = new KeyboardEvent('keydown', {
|
||||
key: key,
|
||||
code: key,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
document.activeElement?.dispatchEvent(keydownEvent);
|
||||
|
||||
const keyupEvent = new KeyboardEvent('keyup', {
|
||||
key: key,
|
||||
code: key,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
document.activeElement?.dispatchEvent(keyupEvent);
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
await executeInBrowserView(app, script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element exists in browser view
|
||||
*/
|
||||
export async function elementExists(app: ElectronApplication, selector: string): Promise<boolean> {
|
||||
try {
|
||||
// Check if selector contains :has-text() pseudo-selector
|
||||
const hasTextMatch = selector.match(/^(.+):has-text\(['"](.+)['"]\)$/);
|
||||
|
||||
if (hasTextMatch) {
|
||||
const baseSelector = hasTextMatch[1];
|
||||
const textContent = hasTextMatch[2];
|
||||
|
||||
const script = `
|
||||
(function() {
|
||||
const elements = document.querySelectorAll('${baseSelector.replace(/'/g, "\\'")}');
|
||||
for (const el of elements) {
|
||||
if (el.textContent && el.textContent.includes('${textContent.replace(/'/g, "\\'")}')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`;
|
||||
|
||||
return await executeInBrowserView<boolean>(app, script);
|
||||
} else {
|
||||
const script = `document.querySelector('${selector.replace(/'/g, "\\'")}') !== null`;
|
||||
return await executeInBrowserView<boolean>(app, script);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { WebContentsView } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import type { ElectronApplication } from 'playwright';
|
||||
|
||||
/**
|
||||
|
|
@ -94,7 +95,22 @@ export async function getDOMContent(app: ElectronApplication): Promise<string |
|
|||
*/
|
||||
export async function isLoaded(app: ElectronApplication): Promise<boolean> {
|
||||
const webContentsId = await getFirstWebContentsView(app);
|
||||
return webContentsId !== null;
|
||||
if (webContentsId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the WebContents is actually loaded
|
||||
return await app.evaluate(
|
||||
async ({ webContents }, id: number) => {
|
||||
const targetWebContents = webContents.fromId(id);
|
||||
if (!targetWebContents) {
|
||||
return false;
|
||||
}
|
||||
// Check if the page has finished loading
|
||||
return !targetWebContents.isLoading() && targetWebContents.getURL() !== '' && targetWebContents.getURL() !== 'about:blank';
|
||||
},
|
||||
webContentsId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -107,30 +123,37 @@ export async function clickElementWithText(
|
|||
): Promise<void> {
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const text = ${JSON.stringify(text)};
|
||||
const elements = document.querySelectorAll(selector);
|
||||
let found = null;
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const elem = elements[i];
|
||||
const elemText = elem.textContent || elem.innerText || '';
|
||||
if (elemText.trim() === text.trim() || elemText.includes(text)) {
|
||||
found = elem;
|
||||
break;
|
||||
try {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const text = ${JSON.stringify(text)};
|
||||
const elements = document.querySelectorAll(selector);
|
||||
let found = null;
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const elem = elements[i];
|
||||
const elemText = elem.textContent || elem.innerText || '';
|
||||
if (elemText.trim() === text.trim() || elemText.includes(text)) {
|
||||
found = elem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return { error: 'Element with text "' + text + '" not found in selector: ' + selector };
|
||||
}
|
||||
|
||||
found.click();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: error.message || String(error) };
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw new Error('Element with text "' + text + '" not found in selector: ' + selector);
|
||||
}
|
||||
|
||||
found.click();
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
await executeInBrowserView(app, script);
|
||||
const result = await executeInBrowserView(app, script);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
throw new Error(String(result.error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,19 +162,26 @@ export async function clickElementWithText(
|
|||
export async function clickElement(app: ElectronApplication, selector: string): Promise<void> {
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const elem = document.querySelector(selector);
|
||||
|
||||
if (!elem) {
|
||||
throw new Error('Element not found: ' + selector);
|
||||
try {
|
||||
const selector = ${JSON.stringify(selector)};
|
||||
const elem = document.querySelector(selector);
|
||||
|
||||
if (!elem) {
|
||||
return { error: 'Element not found: ' + selector };
|
||||
}
|
||||
|
||||
elem.click();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: error.message || String(error) };
|
||||
}
|
||||
|
||||
elem.click();
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
await executeInBrowserView(app, script);
|
||||
const result = await executeInBrowserView(app, script);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
throw new Error(String(result.error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,28 +193,35 @@ export async function typeText(app: ElectronApplication, selector: string, text:
|
|||
|
||||
const script = `
|
||||
(function() {
|
||||
const selector = '${escapedSelector}';
|
||||
const text = '${escapedText}';
|
||||
const elem = document.querySelector(selector);
|
||||
|
||||
if (!elem) {
|
||||
throw new Error('Element not found: ' + selector);
|
||||
try {
|
||||
const selector = '${escapedSelector}';
|
||||
const text = '${escapedText}';
|
||||
const elem = document.querySelector(selector);
|
||||
|
||||
if (!elem) {
|
||||
return { error: 'Element not found: ' + selector };
|
||||
}
|
||||
|
||||
elem.focus();
|
||||
if (elem.tagName === 'TEXTAREA' || elem.tagName === 'INPUT') {
|
||||
elem.value = text;
|
||||
} else {
|
||||
elem.textContent = text;
|
||||
}
|
||||
|
||||
elem.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
elem.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: error.message || String(error) };
|
||||
}
|
||||
|
||||
elem.focus();
|
||||
if (elem.tagName === 'TEXTAREA' || elem.tagName === 'INPUT') {
|
||||
elem.value = text;
|
||||
} else {
|
||||
elem.textContent = text;
|
||||
}
|
||||
|
||||
elem.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
elem.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
})()
|
||||
`;
|
||||
|
||||
await executeInBrowserView(app, script);
|
||||
const result = await executeInBrowserView(app, script);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
throw new Error(String(result.error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -252,3 +289,60 @@ export async function elementExists(app: ElectronApplication, selector: string):
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture screenshot of WebContentsView with timeout
|
||||
* Returns true if screenshot capture started successfully, false if failed or timeout
|
||||
* File writing continues asynchronously in background if capture succeeds
|
||||
*/
|
||||
export async function captureScreenshot(app: ElectronApplication, screenshotPath: string): Promise<boolean> {
|
||||
try {
|
||||
// Add timeout to prevent screenshot from blocking test execution
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(null);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
const capturePromise = (async () => {
|
||||
const webContentsId = await getFirstWebContentsView(app);
|
||||
if (!webContentsId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pngBufferData = await app.evaluate(
|
||||
async ({ webContents }, id: number) => {
|
||||
const targetWebContents = webContents.fromId(id);
|
||||
if (!targetWebContents || targetWebContents.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await targetWebContents.capturePage();
|
||||
const pngBuffer = image.toPNG();
|
||||
return Array.from(pngBuffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
webContentsId,
|
||||
);
|
||||
|
||||
return pngBufferData;
|
||||
})();
|
||||
|
||||
const result = await Promise.race([capturePromise, timeoutPromise]);
|
||||
|
||||
// If we got the screenshot data, write it to file asynchronously (fire and forget)
|
||||
if (result && Array.isArray(result)) {
|
||||
fs.writeFile(screenshotPath, Buffer.from(result)).catch(() => {
|
||||
// Silently ignore write errors
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ Feature: TidGi Mini Window
|
|||
Then the browser view should be loaded and visible
|
||||
And I should see "我的 TiddlyWiki" in the browser view content
|
||||
Then I switch to "main" window
|
||||
And I wait for 0.2 seconds
|
||||
When I press the key combination "CommandOrControl+Shift+M"
|
||||
And I wait for 2 seconds
|
||||
And I confirm the "tidgiMiniWindow" window exists
|
||||
And I confirm the "tidgiMiniWindow" window not visible
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue