mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-06 02:30:47 -08:00
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:
parent
ea78df0ab3
commit
9a6f3480f5
81 changed files with 5008 additions and 991 deletions
|
|
@ -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('已成功') |
|
||||
|
|
|
|||
156
features/filesystemPlugin.feature
Normal file
156
features/filesystemPlugin.feature
Normal 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']"
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
59
features/stepDefinitions/cleanup.ts
Normal file
59
features/stepDefinitions/cleanup.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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)`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ===');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
242
features/supports/webContentsViewHelper.new.ts
Normal file
242
features/supports/webContentsViewHelper.new.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue