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 commit 983b1c623c.

* 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 commit 86aa838d24.

* 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:
lin onetwo 2025-10-31 02:00:40 +08:00 committed by GitHub
parent 7473612cec
commit 7f5e1aa0cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1787 additions and 778 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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