Feat/watch fs (#649)

* Add watch-filesystem-adaptor plugin and worker IPC

Introduces the watch-filesystem-adaptor TiddlyWiki plugin, enabling tag-based routing of tiddlers to sub-wikis by querying workspace info via worker thread IPC. Adds workerServiceCaller utility for worker-to-main service calls, updates workerAdapter and bindServiceAndProxy to support explicit service registration for workers, and documents the new IPC architecture. Updates wikiWorker and startNodeJSWiki to preload workspace ID and load the new plugin. Also updates the plugin build script to compile and copy the new plugin.

* test: wiki operation steps

* Add per-wiki labeled logging and console hijack

Introduces labeled loggers for each wiki, writing logs to separate files. Adds a logFor method to NativeService for logging with labels, updates interfaces, and hijacks worker thread console methods to redirect logs to main process for wiki-specific logging. Refactors workspaceID usage to workspace object for improved context.

* Update log handling for wiki worker and tests

Enhanced logging tests to check all log files, including wiki logs. Adjusted logger to write wiki worker logs to the main log directory. Updated e2e app script comment for correct usage.

* Enable worker thread access to main process services

Introduces a proxy system allowing worker threads to call main process services with full type safety and observable support. Adds worker-side service proxy creation, auto-attaches proxies to global.service, and updates service registration to use IPC descriptors. Documentation is added for usage and architecture.

* Update ErrorDuringStart.md

* chore: upgrade ipc cat and allow clean vite cache

* Refactor wiki worker initialization and service readiness

Moved wiki worker implementation from wikiWorker.ts to wikiWorker/index.ts and deleted the old file. Added servicesReady.ts to manage worker service readiness and callbacks, and integrated notifyServicesReady into the worker lifecycle. Updated console hijack logic to wait for service readiness before hijacking. Improved worker management in Wiki service to support detaching workers and notifying readiness.

* Refactor wiki logging to use centralized logger

Removed per-wiki loggers and console hijacking in favor of a single labeled logger. All wiki logs, including errors, are now written to a unified log file. Updated worker and service code to route logs through the main logger and removed obsolete log file naming and management logic.

* fix: ipc cat log error

* Refactor wiki test paths and improve file save logic

Updated test step to use wikiTestRootPath for directory replacements and added wikiTestRootPath to paths.ts for clarity. Improved error handling and directory logic in watch-filesystem-adaptor.ts, including saving tiddlers directly to sub-wiki folders, more informative logging, and ensuring cleanup after file writes is properly awaited.

* rename

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* feat: basic watch-fs

* feat: check file not exist

* refactor: use exponential-backoff

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* Initial commit when init a new git.

* fix: cleanup

* Refactor test setup and cleanup to separate file

Moved Before and After hooks from application.ts to a new cleanup.ts file for better organization and separation of concerns. Also removed unused imports and related code from application.ts. Minor type simplification in agent.ts for row parsing.

* test: modify and rename

* feat: enableFileSystemWatch

* refactor: unused utils.ts

* Update watch-filesystem-adaptor.ts

* refactor: use node-sentinel-file-watcher

* refactor: extract to two classes

* The logFor method lacks JSDoc describing the level parameter's

* Update startNodeJSWiki.ts

* fix: napi build

* Update electron-rebuild command in workflows

Changed the electron-rebuild command in release and test GitHub Actions workflows to use a comma-separated list for native modules instead of multiple -w flags. This simplifies the rebuild step for better-sqlite3 and nsfw modules.

* lint

* not build nsfw, try use prebuild

* Update package.json

* Update workerAdapter.ts

* remove subWikiPlugin.ts as we use new filesystem adaptor that supports tag based sub wiki

* fix: build

* fix: wrong type

* lint

* remove `act(...)` warnings

* uninstall chokidar

* refactor and test

* lint

* remove unused logic, as we already use ipc syncadaptor, not afriad of wiki status change

* Update translation.json

* test: increast timeout in CI

* Update application.ts

* fix: AI's wrong cleanup logic hidden by as unknown as

* fix: AI's wrong  as unknown as

* Update agent.feature

* Update wikiSearchPlugin.ts

* fix: A dynamic import callback was not specified.
This commit is contained in:
lin onetwo 2025-10-28 13:25:46 +08:00 committed by GitHub
parent ea78df0ab3
commit 9a6f3480f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 5008 additions and 991 deletions

View file

@ -43,7 +43,7 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation
Scenario: Wiki operation
Given I have started the mock OpenAI server
| response | stream |
| <tool_use name="wiki-operation">{"workspaceName":"default","operation":"wiki-add-tiddler","title":"testNote","text":"test"}</tool_use> | false |
| <tool_use name="wiki-operation">{"workspaceName":"test-expected-to-fail","operation":"wiki-add-tiddler","title":"testNote","text":"test"}</tool_use> | false |
| <tool_use name="wiki-operation">{"workspaceName":"wiki","operation":"wiki-add-tiddler","title":"test","text":""}</tool_use>使 wiki | false |
| wiki "test" | false |
# Step 1: Start a fresh tab and run the two-round wiki operation flow
@ -55,13 +55,13 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation
# Step 3: Select agent from autocomplete (not new tab)
When I click on an "agent suggestion" element with selector '[data-autocomplete-source-id="agentsSource"] .aa-ItemWrapper'
And I should see a "message input box" element with selector "[data-testid='agent-message-input']"
# First round: try create note using default workspace (expected to fail)
# First round: try create note using test-expected-to-fail workspace (expected to fail)
When I click on a "message input textarea" element with selector "[data-testid='agent-message-input']"
When I type " wiki test" in "chat input" element with selector "[data-testid='agent-message-input']"
And I press "Enter" key
Then I should see 6 messages in chat history
# Verify there's an error message about workspace not found (in one of the middle messages)
And I should see a "workspace not exist error" element with selector "[data-testid='message-bubble']:has-text('default'):has-text('')"
And I should see a "workspace not exist error" element with selector "[data-testid='message-bubble']:has-text('test-expected-to-fail'):has-text('')"
# Verify the last message contains success confirmation
And I should see "success in last message and wiki workspace in last message" elements with selectors:
| [data-testid='message-bubble']:last-child:has-text('') |

View file

@ -0,0 +1,156 @@
Feature: Filesystem Plugin
As a user
I want tiddlers with specific tags to be saved to sub-wikis automatically
So that I can organize content across wikis
Background:
Given I cleanup test wiki so it could create a new one on start
And I launch the TidGi application
And I wait for the page to load completely
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
Then the browser view should be loaded and visible
And I wait for SSE and watch-fs to be ready
@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"
And I switch to "addWorkspace" window
# Toggle to sub-workspace mode by clicking the switch
And I click on a "main/sub workspace switch" element with selector "[data-testid='main-sub-workspace-switch']"
# Select the first (default) wiki workspace from dropdown
And I select "wiki" from MUI Select with test id "main-wiki-select"
# Type folder name
And I type "SubWiki" in "sub wiki folder name input" element with selector "input[aria-describedby*='-helper-text'][value='wiki']"
And I type "TestTag" in "tag name input" element with selector "[data-testid='tagname-autocomplete-input']"
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='']"
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"
@file-watching
Scenario: External file creation syncs to wiki
# Create a test tiddler file directly on filesystem
When I create file "{tmpDir}/wiki/tiddlers/WatchTestTiddler.tid" with content:
"""
created: 20250226070000000
modified: 20250226070000000
title: WatchTestTiddler
Initial content from filesystem
"""
# Wait for watch-fs to detect and add the tiddler
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
# 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
Then I should see "Initial content from filesystem" in the browser view content
@file-watching
Scenario: External file modification and deletion sync to wiki
# Create initial file
When I create file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" with content:
"""
created: 20250226070000000
modified: 20250226070000000
title: TestTiddler
Original content
"""
Then I wait for tiddler "TestTiddler" to be added by watch-fs
# Open the tiddler to view it
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')"
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)
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"
Then I wait for tiddler "TestTiddler" to be deleted by watch-fs
# Re-open timeline to see updated list
And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('')"
# 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: External file rename syncs to wiki
# Create initial file
When I create file "{tmpDir}/wiki/tiddlers/OldName.tid" with content:
"""
created: 20250226070000000
modified: 20250226070000000
title: OldName
Content before rename
"""
Then I wait for tiddler "OldName" to be added by watch-fs
# Open sidebar 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
And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('OldName')"
Then I should see "Content before rename" in the browser view content
# Rename the file externally
When I rename file "{tmpDir}/wiki/tiddlers/OldName.tid" to "{tmpDir}/wiki/tiddlers/NewName.tid"
# Update the title field in the renamed file to match the new filename
And I modify file "{tmpDir}/wiki/tiddlers/NewName.tid" to contain:
"""
created: 20250226070000000
modified: 20250226070000000
title: NewName
Content before rename
"""
# Wait for the new tiddler to be detected and synced
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
# 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
@file-watching
Scenario: External field modification syncs to wiki
# Modify an existing tiddler file by adding a tags field to TiddlyWikiIconBlue.png
When I modify file "{tmpDir}/wiki/tiddlers/TiddlyWikiIconBlue.png.tid" to add field "tags: TestTag"
Then I wait for tiddler "TiddlyWikiIconBlue.png" to be updated by watch-fs
# Open the tiddler to verify the tag was added
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 1 seconds
And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TiddlyWikiIconBlue.png')"
And I wait for 1 seconds
# Verify the tag appears in the tiddler using data attribute
Then I should see a "TestTag tag" element in browser view with selector "[data-tiddler-title='TiddlyWikiIconBlue.png'] [data-tag-title='TestTag']"
# Now modify Index.tid by adding a tags field
When I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to add field "tags: AnotherTag"
Then I wait for tiddler "Index" to be updated by watch-fs
And I wait for 1 seconds
# Index is displayed by default, verify the AnotherTag appears in Index tiddler
Then I should see a "AnotherTag tag" element in browser view with selector "[data-tiddler-title='Index'] [data-tag-title='AnotherTag']"
# Modify favicon.ico.meta file by adding a tags field
When I modify file "{tmpDir}/wiki/tiddlers/favicon.ico.meta" to add field "tags: IconTag"
Then I wait for tiddler "favicon.ico" to be updated by watch-fs
# Navigate to favicon.ico tiddler
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[href='#favicon.ico']"
And I wait for 1 seconds
# Verify the IconTag appears in favicon.ico tiddler
Then I should see a "IconTag tag" element in browser view with selector "[data-tiddler-title='favicon.ico'] [data-tag-title='IconTag']"

View file

@ -5,9 +5,10 @@ Feature: Renderer logging to backend (UI-driven)
And I wait for the page to load completely
@logging
Scenario: Renderer logs appear in backend log file
Scenario: Renderer logs appear in backend log file and Wiki worker logs appear in same log directory
When I click on a "settings button" element with selector "#open-preferences-button"
When I switch to "preferences" window
When I click on a "sync section" element with selector "[data-testid='preference-section-sync']"
Then I should find log entries containing
| Preferences section clicked |
| test-id-Preferences section clicked |
| test-id-WorkerServicesReady |

View file

@ -8,7 +8,7 @@ Feature: OAuth Login Flow
And I wait for the page to load completely
And I should see a "page body" element with selector "body"
@oauth @pkce
@oauth
Scenario: Login with Custom OAuth Server using PKCE
# Step 1: Start Mock OAuth Server
When I start Mock OAuth Server on port 8888

View file

@ -8,7 +8,7 @@ Feature: TidGi Preference
And I wait for the page to load completely
And I should see a "page body" element with selector "body"
@setup
@ai-setting
Scenario: Configure AI provider and default model
# Step 1: Configure AI settings first - Open preferences window, wait a second so its URL settle down.
When I click on a "settings button" element with selector "#open-preferences-button"

View file

@ -1,5 +1,6 @@
import { After, DataTable, Given, Then } from '@cucumber/cucumber';
import { AIGlobalSettings, AIProviderConfig } from '@services/externalAPI/interface';
import { backOff } from 'exponential-backoff';
import fs from 'fs-extra';
import { isEqual, omit } from 'lodash';
import path from 'path';
@ -8,6 +9,13 @@ import { MockOpenAIServer } from '../supports/mockOpenAI';
import { settingsPath } from '../supports/paths';
import type { ApplicationWorld } from './application';
// Backoff configuration for retries
const BACKOFF_OPTIONS = {
numOfAttempts: 10,
startingDelay: 200,
timeMultiple: 1.5,
};
/**
* Generate deterministic embedding vector based on a semantic tag
* This allows us to control similarity in tests without writing full 384-dim vectors
@ -61,9 +69,9 @@ Given('I have started the mock OpenAI server', function(this: ApplicationWorld,
// Skip header row
for (let index = 1; index < rows.length; index++) {
const row = rows[index];
const response = String(row[0] ?? '').trim();
const stream = String(row[1] ?? '').trim().toLowerCase() === 'true';
const embeddingTag = String(row[2] ?? '').trim();
const response = (row[0] ?? '').trim();
const stream = (row[1] ?? '').trim().toLowerCase() === 'true';
const embeddingTag = (row[2] ?? '').trim();
// Generate embedding from semantic tag if provided
let embedding: number[] | undefined;
@ -111,43 +119,36 @@ Then('I should see {int} messages in chat history', async function(this: Applica
throw new Error('No current window is available');
}
// Use precise selector based on the provided HTML structure
const messageSelector = '[data-testid="message-bubble"]';
try {
// Wait for messages to reach expected count, checking periodically for streaming
for (let attempt = 1; attempt <= expectedCount * 3; attempt++) {
try {
// Wait for at least one message to exist
await currentWindow.waitForSelector(messageSelector, { timeout: 5000 });
await backOff(
async () => {
// Wait for at least one message to exist
await currentWindow.waitForSelector(messageSelector, { timeout: 5000 });
// Count current messages
const messages = currentWindow.locator(messageSelector);
const currentCount = await messages.count();
// Count current messages
const messages = currentWindow.locator(messageSelector);
const currentCount = await messages.count();
if (currentCount === expectedCount) {
return;
} else if (currentCount > expectedCount) {
throw new Error(`Expected ${expectedCount} messages but found ${currentCount} (too many)`);
}
// If not enough messages yet, wait a bit more for streaming
if (attempt < expectedCount * 3) {
await currentWindow.waitForTimeout(2000);
}
} catch (timeoutError) {
if (attempt === expectedCount * 3) {
throw timeoutError;
}
if (currentCount === expectedCount) {
return; // Success
} else if (currentCount > expectedCount) {
throw new Error(`Expected ${expectedCount} messages but found ${currentCount} (too many)`);
} else {
// Not enough messages yet, throw to trigger retry
throw new Error(`Expected ${expectedCount} messages but found ${currentCount}`);
}
},
BACKOFF_OPTIONS,
).catch(async (error: unknown) => {
// Get final count for error message
try {
const finalCount = await currentWindow.locator(messageSelector).count();
throw new Error(`Could not find expected ${expectedCount} messages. Found ${finalCount}. Error: ${(error as Error).message}`);
} catch {
throw new Error(`Could not find expected ${expectedCount} messages. Error: ${(error as Error).message}`);
}
// Final attempt to get the count
const finalCount = await currentWindow.locator(messageSelector).count();
throw new Error(`Expected ${expectedCount} messages but found ${finalCount} after waiting for streaming to complete`);
} catch (error) {
throw new Error(`Could not find expected ${expectedCount} messages. Error: ${(error as Error).message}`);
}
});
});
Then('the last AI request should contain system prompt {string}', async function(this: ApplicationWorld, expectedPrompt: string) {

View file

@ -1,4 +1,5 @@
import { After, AfterStep, Before, setWorldConstructor, When } from '@cucumber/cucumber';
import { AfterStep, setDefaultTimeout, setWorldConstructor, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import fs from 'fs-extra';
import path from 'path';
import { _electron as electron } from 'playwright';
@ -6,10 +7,15 @@ import type { ElectronApplication, Page } from 'playwright';
import { windowDimension, WindowNames } from '../../src/services/windows/WindowProperties';
import { MockOAuthServer } from '../supports/mockOAuthServer';
import { MockOpenAIServer } from '../supports/mockOpenAI';
import { logsDirectory, makeSlugPath, screenshotsDirectory } from '../supports/paths';
import { makeSlugPath, screenshotsDirectory } from '../supports/paths';
import { getPackedAppPath } from '../supports/paths';
import { clearAISettings } from './agent';
import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow';
// Backoff configuration for retries
const BACKOFF_OPTIONS = {
numOfAttempts: 8,
startingDelay: 100,
timeMultiple: 2,
};
// Helper function to check if window type is valid and return the corresponding WindowNames
export function checkWindowName(windowType: string): WindowNames {
@ -51,23 +57,25 @@ export class ApplicationWorld {
async waitForWindowCondition(
windowType: string,
condition: (window: Page | undefined, isVisible: boolean) => boolean,
maxAttempts: number = 3,
retryInterval: number = 250,
): Promise<boolean> {
if (!this.app) return false;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const targetWindow = await this.findWindowByType(windowType);
const visible = targetWindow ? await this.isWindowVisible(targetWindow) : false;
try {
await backOff(
async () => {
const targetWindow = await this.findWindowByType(windowType);
const visible = targetWindow ? await this.isWindowVisible(targetWindow) : false;
if (condition(targetWindow, visible)) {
return true;
}
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, retryInterval));
if (!condition(targetWindow, visible)) {
throw new Error('Condition not met');
}
},
BACKOFF_OPTIONS,
);
return true;
} catch {
return false;
}
return false;
}
// Helper method to find window by type - strict WindowNames matching
@ -158,80 +166,33 @@ export class ApplicationWorld {
return this.currentWindow;
}
// Use the findWindowByType method with retry logic
for (let attempt = 0; attempt < 3; attempt++) {
try {
const window = await this.findWindowByType(windowType);
if (window) return window;
} catch (error) {
// If it's an invalid window type error, throw immediately
if (error instanceof Error && error.message.includes('is not a valid WindowNames')) {
throw error;
}
}
// If window not found, wait and retry (except for the last attempt)
if (attempt < 2) {
await new Promise(resolve => setTimeout(resolve, 1000));
// Use the findWindowByType method with retry logic using backoff
try {
return await backOff(
async () => {
const window = await this.findWindowByType(windowType);
if (!window) {
throw new Error(`Window ${windowType} not found`);
}
return window;
},
BACKOFF_OPTIONS,
);
} catch (error) {
// If it's an invalid window type error, re-throw it
if (error instanceof Error && error.message.includes('is not a valid WindowNames')) {
throw error;
}
return undefined;
}
return undefined;
}
}
setWorldConstructor(ApplicationWorld);
// setDefaultTimeout(50000);
Before(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 });
}
// Create screenshots subdirectory in logs
if (!fs.existsSync(screenshotsDirectory)) {
fs.mkdirSync(screenshotsDirectory, { recursive: true });
}
if (pickle.tags.some((tag) => tag.name === '@setup')) {
clearAISettings();
}
if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) {
clearTidgiMiniWindowSettings();
}
});
After(async function(this: ApplicationWorld, { pickle }) {
if (this.app) {
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();
}
} catch (error) {
console.error('Error closing window:', error);
}
}
await this.app.close();
} catch (error) {
console.error('Error during cleanup:', error);
}
this.app = undefined;
this.mainWindow = undefined;
this.currentWindow = undefined;
}
if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) {
clearTidgiMiniWindowSettings();
}
if (pickle.tags.some((tag) => tag.name === '@setup')) {
clearAISettings();
}
});
if (process.env.CI) {
setDefaultTimeout(50000);
}
AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) {
// Only take screenshots in CI environment

View file

@ -1,10 +1,14 @@
import { Then } from '@cucumber/cucumber';
import { getDOMContent, getTextContent, isLoaded } from '../supports/webContentsViewHelper';
import { Then, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import { clickElement, clickElementWithText, elementExists, getDOMContent, getTextContent, isLoaded, pressKey, typeText } from '../supports/webContentsViewHelper';
import type { ApplicationWorld } from './application';
// Constants for retry logic
const MAX_ATTEMPTS = 3;
const RETRY_INTERVAL_MS = 500;
// Backoff configuration for retries
const BACKOFF_OPTIONS = {
numOfAttempts: 8,
startingDelay: 100,
timeMultiple: 2,
};
Then('I should see {string} in the browser view content', async function(this: ApplicationWorld, expectedText: string) {
if (!this.app) {
@ -15,29 +19,20 @@ Then('I should see {string} in the browser view content', async function(this: A
throw new Error('No current window available');
}
// Retry logic to check for expected text in content
const maxAttempts = MAX_ATTEMPTS;
const retryInterval = RETRY_INTERVAL_MS;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const content = await getTextContent(this.app);
if (content && content.includes(expectedText)) {
return; // Success, exit early
}
// Wait before retrying (except for the last attempt)
if (attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
// Final attempt to get content for error message
const finalContent = await getTextContent(this.app);
throw new Error(
`Expected text "${expectedText}" not found in browser view content after ${MAX_ATTEMPTS} attempts. Actual content: ${
finalContent ? finalContent.substring(0, 200) + '...' : 'null'
}`,
);
await backOff(
async () => {
const content = await getTextContent(this.app!);
if (!content || !content.includes(expectedText)) {
throw new Error(`Expected text "${expectedText}" not found`);
}
},
BACKOFF_OPTIONS,
).catch(async () => {
const finalContent = await getTextContent(this.app!);
throw new Error(
`Expected text "${expectedText}" not found in browser view content. Actual content: ${finalContent ? finalContent.substring(0, 200) + '...' : 'null'}`,
);
});
});
Then('I should see {string} in the browser view DOM', async function(this: ApplicationWorld, expectedText: string) {
@ -49,29 +44,20 @@ Then('I should see {string} in the browser view DOM', async function(this: Appli
throw new Error('No current window available');
}
// Retry logic to check for expected text in DOM
const maxAttempts = MAX_ATTEMPTS;
const retryInterval = RETRY_INTERVAL_MS;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const domContent = await getDOMContent(this.app);
if (domContent && domContent.includes(expectedText)) {
return; // Success, exit early
}
// Wait before retrying (except for the last attempt)
if (attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
// Final attempt to get DOM content for error message
const finalDomContent = await getDOMContent(this.app);
throw new Error(
`Expected text "${expectedText}" not found in browser view DOM after ${MAX_ATTEMPTS} attempts. Actual DOM: ${
finalDomContent ? finalDomContent.substring(0, 200) + '...' : 'null'
}`,
);
await backOff(
async () => {
const domContent = await getDOMContent(this.app!);
if (!domContent || !domContent.includes(expectedText)) {
throw new Error(`Expected text "${expectedText}" not found in DOM`);
}
},
BACKOFF_OPTIONS,
).catch(async () => {
const finalDomContent = await getDOMContent(this.app!);
throw new Error(
`Expected text "${expectedText}" not found in browser view DOM. Actual DOM: ${finalDomContent ? finalDomContent.substring(0, 200) + '...' : 'null'}`,
);
});
});
Then('the browser view should be loaded and visible', async function(this: ApplicationWorld) {
@ -83,21 +69,125 @@ Then('the browser view should be loaded and visible', async function(this: Appli
throw new Error('No current window available');
}
// Retry logic to check if browser view is loaded
const maxAttempts = MAX_ATTEMPTS;
const retryInterval = RETRY_INTERVAL_MS;
await backOff(
async () => {
const isLoadedResult = await isLoaded(this.app!);
if (!isLoadedResult) {
throw new Error('Browser view not loaded');
}
},
BACKOFF_OPTIONS,
).catch(() => {
throw new Error('Browser view is not loaded or visible after multiple attempts');
});
});
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const isLoadedResult = await isLoaded(this.app);
if (isLoadedResult) {
return; // Success, exit early
}
// Wait before retrying (except for the last attempt)
if (attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
When('I click on {string} element in browser view with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
if (!this.app) {
throw new Error('Application not launched');
}
throw new Error(`Browser view is not loaded or visible after ${MAX_ATTEMPTS} attempts`);
try {
// Check if selector contains :has-text() pseudo-selector
const hasTextMatch = selector.match(/^(.+):has-text\(['"](.+)['"]\)$/);
if (hasTextMatch) {
// Extract base selector and text content
const baseSelector = hasTextMatch[1];
const textContent = hasTextMatch[2];
await clickElementWithText(this.app, baseSelector, textContent);
} else {
// Use regular selector
await clickElement(this.app, selector);
}
} catch (error) {
throw new Error(`Failed to click ${elementComment} element with selector "${selector}" in browser view: ${error as Error}`);
}
});
When('I type {string} in {string} element in browser view with selector {string}', async function(this: ApplicationWorld, text: string, elementComment: string, selector: string) {
if (!this.app) {
throw new Error('Application not launched');
}
try {
await typeText(this.app, selector, text);
} catch (error) {
throw new Error(`Failed to type in ${elementComment} element with selector "${selector}" in browser view: ${error as Error}`);
}
});
When('I press {string} in browser view', async function(this: ApplicationWorld, key: string) {
if (!this.app) {
throw new Error('Application not launched');
}
try {
await pressKey(this.app, key);
} catch (error) {
throw new Error(`Failed to press key "${key}" in browser view: ${error as Error}`);
}
});
Then('I should not see {string} in the browser view content', async function(this: ApplicationWorld, unexpectedText: string) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
// Wait a bit for UI to update
await new Promise(resolve => setTimeout(resolve, 500));
// Check that text does not exist in content
const content = await getTextContent(this.app);
if (content && content.includes(unexpectedText)) {
throw new Error(`Unexpected text "${unexpectedText}" found in browser view content`);
}
});
Then('I should not see a(n) {string} element in browser view with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
await backOff(
async () => {
const exists: boolean = await elementExists(this.app!, selector);
if (exists) {
throw new Error('Element still exists');
}
},
BACKOFF_OPTIONS,
).catch(() => {
throw new Error(`Element "${elementComment}" with selector "${selector}" was found in browser view after multiple attempts, but should not be visible`);
});
});
Then('I should see a(n) {string} element in browser view with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
await backOff(
async () => {
const exists: boolean = await elementExists(this.app!, selector);
if (!exists) {
throw new Error('Element does not exist yet');
}
},
BACKOFF_OPTIONS,
).catch(() => {
throw new Error(`Element "${elementComment}" with selector "${selector}" not found in browser view after multiple attempts`);
});
});

View file

@ -0,0 +1,59 @@
import { After, Before } from '@cucumber/cucumber';
import fs from 'fs-extra';
import { logsDirectory, screenshotsDirectory } from '../supports/paths';
import { clearAISettings } from './agent';
import { ApplicationWorld } from './application';
import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow';
import { clearSubWikiRoutingTestData } from './wiki';
Before(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 });
}
// Create screenshots subdirectory in logs
if (!fs.existsSync(screenshotsDirectory)) {
fs.mkdirSync(screenshotsDirectory, { recursive: true });
}
if (pickle.tags.some((tag) => tag.name === '@ai-setting')) {
clearAISettings();
}
if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) {
clearTidgiMiniWindowSettings();
}
});
After(async function(this: ApplicationWorld, { pickle }) {
if (this.app) {
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();
}
} catch (error) {
console.error('Error closing window:', error);
}
}
await this.app.close();
} catch (error) {
console.error('Error during cleanup:', error);
}
this.app = undefined;
this.mainWindow = undefined;
this.currentWindow = undefined;
}
if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) {
clearTidgiMiniWindowSettings();
}
if (pickle.tags.some((tag) => tag.name === '@ai-setting')) {
clearAISettings();
}
if (pickle.tags.some((tag) => tag.name === '@subwiki')) {
clearSubWikiRoutingTestData();
}
});

View file

@ -8,14 +8,23 @@ import { ApplicationWorld } from './application';
Then('I should find log entries containing', async function(this: ApplicationWorld, dataTable: DataTable | undefined) {
const expectedRows = dataTable?.raw().map((r: string[]) => r[0]);
// Only consider normal daily log files like TidGi-2025-08-27.log and exclude exception logs
const files = fs.readdirSync(logsDirectory).filter((f) => /TidGi-\d{4}-\d{2}-\d{2}\.log$/.test(f));
const latestLogFilePath = files.length > 0 ? files.sort().reverse()[0] : null;
const content = latestLogFilePath ? fs.readFileSync(path.join(logsDirectory, latestLogFilePath), 'utf8') : '<no-log-found>';
// Consider all log files in logs directory (including wiki logs like wiki-2025-10-25.log)
const files = fs.readdirSync(logsDirectory).filter((f) => f.endsWith('.log'));
const missing = expectedRows?.filter((expectedRow: string) => {
// Check if any log file contains this expected content
return !files.some((file) => {
try {
const content = fs.readFileSync(path.join(logsDirectory, file), 'utf8');
return content.includes(expectedRow);
} catch {
return false;
}
});
});
const missing = expectedRows?.filter((r: string) => !content.includes(r));
if (missing?.length) {
throw new Error(`Missing expected log messages "${missing.map(item => item.slice(0, 10)).join('...", "')}..." on latest log file: ${latestLogFilePath}`);
throw new Error(`Missing expected log messages in ${files.length} log file(s)`);
}
});

View file

@ -466,3 +466,94 @@ When('I select {string} from MUI Select with test id {string}', async function(t
throw new Error(`Failed to select option "${optionValue}" from MUI Select with test id "${testId}": ${String(error)}`);
}
});
// Debug step to print current DOM structure
When('I print current DOM structure', async function(this: ApplicationWorld) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
const html = await currentWindow.evaluate(() => {
return document.body.innerHTML;
});
console.log('=== Current DOM Structure ===');
console.log(html.substring(0, 5000)); // Print first 5000 characters
console.log('=== End DOM Structure ===');
});
// Debug step to print DOM structure of a specific element
When('I print DOM structure of element with selector {string}', async function(this: ApplicationWorld, selector: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
try {
await currentWindow.waitForSelector(selector, { timeout: 5000 });
const elementInfo = await currentWindow.evaluate((sel) => {
const element = document.querySelector(sel);
if (!element) {
return { found: false };
}
return {
found: true,
outerHTML: element.outerHTML,
innerHTML: element.innerHTML,
attributes: Array.from(element.attributes).map(attribute => ({
name: attribute.name,
value: attribute.value,
})),
children: Array.from(element.children).map(child => ({
tagName: child.tagName,
className: child.className,
id: child.id,
attributes: Array.from(child.attributes).map(attribute => ({
name: attribute.name,
value: attribute.value,
})),
})),
};
}, selector);
if (!elementInfo.found) {
console.log(`=== Element "${selector}" not found ===`);
return;
}
console.log(`=== DOM Structure of "${selector}" ===`);
console.log('Attributes:', JSON.stringify(elementInfo.attributes, null, 2));
console.log('\nChildren:', JSON.stringify(elementInfo.children, null, 2));
console.log('\nOuter HTML (first 2000 chars):');
console.log((elementInfo.outerHTML ?? '').substring(0, 2000));
console.log('=== End DOM Structure ===');
} catch (error) {
console.log(`Error inspecting element "${selector}": ${String(error)}`);
}
});
// Debug step to print all window URLs
When('I print all window URLs', async function(this: ApplicationWorld) {
if (!this.app) {
throw new Error('Application is not available');
}
const allWindows = this.app.windows();
console.log(`=== Total windows: ${allWindows.length} ===`);
for (let index = 0; index < allWindows.length; index++) {
const win = allWindows[index];
try {
const url = win.url();
const title = await win.title();
const isClosed = win.isClosed();
console.log(`Window ${index}: URL=${url}, Title=${title}, Closed=${isClosed}`);
} catch (error) {
console.log(`Window ${index}: Error getting info - ${String(error)}`);
}
}
console.log('=== End Window List ===');
});

View file

@ -1,11 +1,152 @@
import { When } from '@cucumber/cucumber';
import { Then, When } from '@cucumber/cucumber';
import fs from 'fs-extra';
import path from 'path';
import type { IWorkspace } from '../../src/services/workspaces/interface';
import { settingsPath, wikiTestWikiPath } from '../supports/paths';
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`);
}
/**
* Wait for a tiddler to be added by watch-fs.
*/
async function waitForTiddlerAdded(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_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;
}
}
} 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`);
}
When('I cleanup test wiki so it could create a new one on start', async function() {
if (fs.existsSync(wikiTestWikiPath)) fs.removeSync(wikiTestWikiPath);
/**
* Clean up wiki log files to prevent reading stale logs from previous scenarios.
* This is critical for tests that wait for log markers like [test-id-WATCH_FS_STABILIZED],
* as Node.js file system caching can cause tests to read old log content.
*/
const logDirectory = path.join(process.cwd(), 'userData-test', 'logs');
if (fs.existsSync(logDirectory)) {
const logFiles = fs.readdirSync(logDirectory).filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
for (const logFile of logFiles) {
fs.removeSync(path.join(logDirectory, logFile));
}
}
type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>;
if (!fs.existsSync(settingsPath)) return;
const settings = fs.readJsonSync(settingsPath) as SettingsFile;
@ -19,3 +160,184 @@ When('I cleanup test wiki so it could create a new one on start', async function
}
fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 });
});
/**
* Verify file exists in directory
*/
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));
}
if (!exists) {
throw new Error(`File "${fileName}" not found in directory: ${actualPath}`);
}
});
/**
* Cleanup function for sub-wiki routing test
* Removes test workspaces created during the test
*/
function clearSubWikiRoutingTestData() {
if (!fs.existsSync(settingsPath)) return;
type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>;
const settings = fs.readJsonSync(settingsPath) as SettingsFile;
const workspaces: Record<string, IWorkspace> = settings.workspaces ?? {};
const filtered: Record<string, IWorkspace> = {};
// Remove test workspaces (SubWiki, etc from sub-wiki routing tests)
for (const id of Object.keys(workspaces)) {
const ws = workspaces[id];
const name = ws.name;
// Keep workspaces that don't match test patterns
if (name !== 'SubWiki') {
filtered[id] = ws;
}
}
fs.writeJsonSync(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);
}
}
}
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 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}`);
}
});
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}`);
}
});
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}`);
}
});
// File manipulation step definitions
When('I create file {string} with content:', async function(this: ApplicationWorld, filePath: string, content: string) {
// Replace {tmpDir} placeholder with actual temp directory
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
// Ensure directory exists
await fs.ensureDir(path.dirname(actualPath));
// Write the file with the provided content
await fs.writeFile(actualPath, content, 'utf-8');
});
When('I modify file {string} to contain {string}', async function(this: ApplicationWorld, filePath: string, content: string) {
// Replace {tmpDir} placeholder with actual temp directory
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
// Read the existing file
let fileContent = await fs.readFile(actualPath, 'utf-8');
// TiddlyWiki .tid files have a format: headers followed by blank line and text
// We need to preserve headers and only modify the text part
const lines = fileContent.split('\n');
const blankLineIndex = lines.findIndex(line => line.trim() === '');
if (blankLineIndex >= 0) {
// Keep headers, replace text after blank line
const headers = lines.slice(0, blankLineIndex + 1);
fileContent = [...headers, content].join('\n');
} else {
// No headers found, just use content
fileContent = content;
}
// Write the modified content back
await fs.writeFile(actualPath, fileContent, 'utf-8');
});
When('I modify file {string} to contain:', async function(this: ApplicationWorld, filePath: string, content: string) {
// Replace {tmpDir} placeholder with actual temp directory
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
// For multi-line content with headers, just write the content directly
// (assumes the content includes all headers and structure)
await fs.writeFile(actualPath, content, 'utf-8');
});
When('I delete file {string}', async function(this: ApplicationWorld, filePath: string) {
// Replace {tmpDir} placeholder with actual temp directory
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
// Delete the file
await fs.remove(actualPath);
});
When('I rename file {string} to {string}', async function(this: ApplicationWorld, oldPath: string, newPath: string) {
// Replace {tmpDir} placeholder with actual temp directory
const actualOldPath = oldPath.replace('{tmpDir}', wikiTestRootPath);
const actualNewPath = newPath.replace('{tmpDir}', wikiTestRootPath);
// Ensure the target directory exists
await fs.ensureDir(path.dirname(actualNewPath));
// Rename/move the file
await fs.rename(actualOldPath, actualNewPath);
});
When('I modify file {string} to add field {string}', async function(this: ApplicationWorld, filePath: string, fieldLine: string) {
// Replace {tmpDir} placeholder with actual temp directory
const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath);
// Read the existing file
const fileContent = await fs.readFile(actualPath, 'utf-8');
// TiddlyWiki .tid files have headers followed by a blank line and text
// We need to add the field to the headers section
const lines = fileContent.split('\n');
const blankLineIndex = lines.findIndex(line => line.trim() === '');
if (blankLineIndex >= 0) {
// Insert the new field before the blank line
lines.splice(blankLineIndex, 0, fieldLine);
} else {
// No blank line found, add to the beginning
lines.unshift(fieldLine);
}
// Write the modified content back
await fs.writeFile(actualPath, lines.join('\n'), 'utf-8');
});
export { clearSubWikiRoutingTestData };

View file

@ -2,10 +2,7 @@ import { When } from '@cucumber/cucumber';
import type { ElectronApplication } from 'playwright';
import type { ApplicationWorld } from './application';
import { checkWindowDimension, checkWindowName } from './application';
// Constants for retry logic
const MAX_ATTEMPTS = 10;
const RETRY_INTERVAL_MS = 1000;
import { WebContentsView } from 'electron';
// Helper function to get browser view info from Electron window
async function getBrowserViewInfo(
@ -32,7 +29,7 @@ async function getBrowserViewInfo(
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
const webContentsView = view as unknown as { getBounds: () => { x: number; y: number; width: number; height: number } };
const webContentsView = view as WebContentsView;
const viewBounds = webContentsView.getBounds();
const windowContentBounds = targetWindow.getContentBounds();
@ -57,8 +54,6 @@ When('I confirm the {string} window exists', async function(this: ApplicationWor
const success = await this.waitForWindowCondition(
windowType,
(window) => window !== undefined && !window.isClosed(),
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
@ -74,12 +69,10 @@ When('I confirm the {string} window visible', async function(this: ApplicationWo
const success = await this.waitForWindowCondition(
windowType,
(window, isVisible) => window !== undefined && !window.isClosed() && isVisible,
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
throw new Error(`${windowType} window was not visible after ${MAX_ATTEMPTS} attempts`);
throw new Error(`${windowType} window was not visible after multiple attempts`);
}
});
@ -91,12 +84,10 @@ 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,
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
throw new Error(`${windowType} window was visible or not found after ${MAX_ATTEMPTS} attempts`);
throw new Error(`${windowType} window was visible or not found after multiple attempts`);
}
});
@ -108,12 +99,10 @@ When('I confirm the {string} window does not exist', async function(this: Applic
const success = await this.waitForWindowCondition(
windowType,
(window) => window === undefined,
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
throw new Error(`${windowType} window still exists after ${MAX_ATTEMPTS} attempts`);
throw new Error(`${windowType} window still exists after multiple attempts`);
}
});

View file

@ -53,7 +53,8 @@ export const settingsPath = path.resolve(settingsDirectory, 'settings.json');
// Repo root and test wiki paths
export const repoRoot = path.resolve(process.cwd());
export const wikiTestWikiPath = path.resolve(repoRoot, 'wiki-test', 'wiki');
export const wikiTestRootPath = path.resolve(repoRoot, 'wiki-test'); // Root of all test wikis
export const wikiTestWikiPath = path.resolve(wikiTestRootPath, 'wiki'); // Main test wiki
// Archive-safe sanitization: generate a slug that is safe for zipping/unzipping across platforms.
// Rules:

View file

@ -0,0 +1,242 @@
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,151 +1,254 @@
import { WebContentsView } from 'electron';
import type { ElectronApplication } from 'playwright';
/**
* Get text content from WebContentsView
* @param app Electron application instance
* @returns Promise<string | null> Returns text content or null
* Get the first WebContentsView from any window
* Prioritizes main window, but will check all windows if needed
*/
export async function getTextContent(app: ElectronApplication): Promise<string | null> {
async function getFirstWebContentsView(app: ElectronApplication) {
return await app.evaluate(async ({ BrowserWindow }) => {
// Get all browser windows
const windows = BrowserWindow.getAllWindows();
const allWindows = BrowserWindow.getAllWindows();
for (const window of windows) {
// Get all child views (WebContentsView instances) attached to this window
if (window.contentView && 'children' in window.contentView) {
const views = window.contentView.children || [];
// First try to find main window
const mainWindow = allWindows.find(w => !w.isDestroyed() && w.webContents?.getType() === 'window');
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
try {
// Cast to WebContentsView type and execute JavaScript
const webContentsView = view as unknown as { webContents: { executeJavaScript: (script: string) => Promise<string> } };
const content = await webContentsView.webContents.executeJavaScript(`
document.body.textContent || document.body.innerText || ''
`);
if (content && content.trim()) {
return content;
}
} catch {
// Continue to next view if this one fails
continue;
}
}
if (mainWindow?.contentView && 'children' in mainWindow.contentView) {
const children = (mainWindow.contentView as WebContentsView).children as WebContentsView[];
if (Array.isArray(children) && children.length > 0) {
const webContentsId = children[0]?.webContents?.id;
if (webContentsId) return webContentsId;
}
}
// If main window doesn't have a WebContentsView, check all windows
for (const window of allWindows) {
if (!window.isDestroyed() && window.contentView && 'children' in window.contentView) {
const children = (window.contentView as WebContentsView).children as WebContentsView[];
if (Array.isArray(children) && children.length > 0) {
const webContentsId = children[0]?.webContents?.id;
if (webContentsId) return webContentsId;
}
}
}
return 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 WebContentsView found in main window');
}
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
* @param app Electron application instance
* @returns Promise<string | null> Returns DOM content or null
*/
export async function getDOMContent(app: ElectronApplication): Promise<string | null> {
return await app.evaluate(async ({ BrowserWindow }) => {
// Get all browser windows
const windows = BrowserWindow.getAllWindows();
for (const window of windows) {
// Get all child views (WebContentsView instances) attached to this window
if (window.contentView && 'children' in window.contentView) {
const views = window.contentView.children || [];
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
try {
// Cast to WebContentsView type and execute JavaScript
const webContentsView = view as unknown as { webContents: { executeJavaScript: (script: string) => Promise<string> } };
const content = await webContentsView.webContents.executeJavaScript(`
document.documentElement.outerHTML || ''
`);
if (content && content.trim()) {
return content;
}
} catch {
// Continue to next view if this one fails
continue;
}
}
}
}
}
try {
return await executeInBrowserView<string>(
app,
'document.documentElement.outerHTML || ""',
);
} catch {
return null;
});
}
}
/**
* Check if WebContentsView exists and is loaded
* @param app Electron application instance
* @returns Promise<boolean> Returns whether it exists and is loaded
*/
export async function isLoaded(app: ElectronApplication): Promise<boolean> {
return await app.evaluate(async ({ BrowserWindow }) => {
// Get all browser windows
const windows = BrowserWindow.getAllWindows();
const webContentsId = await getFirstWebContentsView(app);
return webContentsId !== null;
}
for (const window of windows) {
// Get all child views (WebContentsView instances) attached to this window
if (window.contentView && 'children' in window.contentView) {
const views = window.contentView.children || [];
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
// If we found a WebContentsView, consider it loaded
return true;
}
/**
* 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;
});
}
/**
* Find specified text in WebContentsView
* @param app Electron application instance
* @param expectedText Text to search for
* @param contentType Content type: 'text' or 'dom'
* @returns Promise<boolean> Returns whether text was found
*/
export async function containsText(
app: ElectronApplication,
expectedText: string,
contentType: 'text' | 'dom' = 'text',
): Promise<boolean> {
const content = contentType === 'text'
? await getTextContent(app)
: await getDOMContent(app);
return content !== null && content.includes(expectedText);
}
/**
* Get WebContentsView content summary (for error messages)
* @param app Electron application instance
* @param contentType Content type: 'text' or 'dom'
* @param maxLength Maximum length, default 200
* @returns Promise<string> Returns content summary
*/
export async function getContentSummary(
app: ElectronApplication,
contentType: 'text' | 'dom' = 'text',
maxLength: number = 200,
): Promise<string> {
const content = contentType === 'text'
? await getTextContent(app)
: await getDOMContent(app);
if (!content) {
return 'null';
}
return content.length > maxLength
? content.substring(0, maxLength) + '...'
: content;
}

View file

@ -1,4 +1,4 @@
@tidgiminiwindow
@tidgi-mini-window
Feature: TidGi Mini Window
As a user
I want to enable and use the TidGi mini window

View file

@ -1,4 +1,4 @@
@tidgiminiwindow
@tidgi-mini-window
Feature: TidGi Mini Window Workspace Switching
As a user with tidgi mini window already enabled
I want to test tidgi mini window behavior with different workspace configurations