TidGi-Desktop/features/stepDefinitions/application.ts
lin onetwo 9a6f3480f5
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.
2025-10-28 13:25:46 +08:00

319 lines
12 KiB
TypeScript

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';
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 { makeSlugPath, screenshotsDirectory } from '../supports/paths';
import { getPackedAppPath } from '../supports/paths';
// 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 {
// Exact match - windowType must be a valid WindowNames enum key
if (windowType in WindowNames) {
return (WindowNames as Record<string, WindowNames>)[windowType];
}
throw new Error(`Window type "${windowType}" is not a valid WindowNames. Check the WindowNames enum in WindowProperties.ts. Available: ${Object.keys(WindowNames).join(', ')}`);
}
// Helper function to get window dimensions and ensure they are valid
export function checkWindowDimension(windowName: WindowNames): { width: number; height: number } {
const targetDimensions = windowDimension[windowName];
if (!targetDimensions.width || !targetDimensions.height) {
throw new Error(`Window "${windowName}" does not have valid dimensions defined in windowDimension`);
}
return targetDimensions as { width: number; height: number };
}
export class ApplicationWorld {
app: ElectronApplication | undefined;
mainWindow: Page | undefined; // Keep for compatibility during transition
currentWindow: Page | undefined; // New state-managed current window
mockOpenAIServer: MockOpenAIServer | undefined;
mockOAuthServer: MockOAuthServer | undefined;
// Helper method to check if window is visible
async isWindowVisible(page: Page): Promise<boolean> {
if (!this.app) return false;
try {
const browserWindow = await this.app.browserWindow(page);
return await browserWindow.evaluate((win: Electron.BrowserWindow) => win.isVisible());
} catch {
return false;
}
}
// Helper method to wait for window with retry logic
async waitForWindowCondition(
windowType: string,
condition: (window: Page | undefined, isVisible: boolean) => boolean,
): Promise<boolean> {
if (!this.app) return false;
try {
await backOff(
async () => {
const targetWindow = await this.findWindowByType(windowType);
const visible = targetWindow ? await this.isWindowVisible(targetWindow) : false;
if (!condition(targetWindow, visible)) {
throw new Error('Condition not met');
}
},
BACKOFF_OPTIONS,
);
return true;
} catch {
return false;
}
}
// Helper method to find window by type - strict WindowNames matching
async findWindowByType(windowType: string): Promise<Page | undefined> {
if (!this.app) return undefined;
// Validate window type first
const windowName = checkWindowName(windowType);
const pages = this.app.windows();
if (windowName === WindowNames.main) {
// Main window is the first/primary window, typically showing guide, agent, help, or wiki pages
// It's the window that opens on app launch
const allWindows = pages.filter(page => !page.isClosed());
if (allWindows.length > 0) {
// Return the first window (main window is always the first one created)
return allWindows[0];
}
return undefined;
} else if (windowName === WindowNames.tidgiMiniWindow) {
// Special handling for tidgi mini window
// First try to find by Electron window dimensions (more reliable than title)
const windowDimensions = checkWindowDimension(windowName);
try {
const electronWindowInfo = await this.app.evaluate(
async ({ BrowserWindow }, size: { width: number; height: number }) => {
const allWindows = BrowserWindow.getAllWindows();
const tidgiMiniWindow = allWindows.find(win => {
const bounds = win.getBounds();
return bounds.width === size.width && bounds.height === size.height;
});
return tidgiMiniWindow ? { id: tidgiMiniWindow.id } : null;
},
windowDimensions,
);
if (electronWindowInfo) {
// Found by dimensions, now match with Playwright page
const allWindows = pages.filter(page => !page.isClosed());
for (const page of allWindows) {
try {
// Try to match by checking if this page belongs to the found electron window
// For now, use title as fallback verification
const title = await page.title();
if (title.includes('太记小窗') || title.includes('TidGi Mini Window') || title.includes('TidGiMiniWindow')) {
return page;
}
} catch {
continue;
}
}
}
} catch {
// If Electron API fails, fallback to title matching
}
// Fallback: Match by window title
const allWindows = pages.filter(page => !page.isClosed());
for (const page of allWindows) {
try {
const title = await page.title();
if (title.includes('太记小窗') || title.includes('TidGi Mini Window') || title.includes('TidGiMiniWindow')) {
return page;
}
} catch {
// Page might be closed or not ready, continue to next
continue;
}
}
return undefined;
} else {
// For regular windows (preferences, about, addWorkspace, etc.)
return pages.find(page => {
if (page.isClosed()) return false;
const url = page.url() || '';
// Match exact route paths: /#/windowType or ending with /windowType
return url.includes(`#/${windowType}`) || url.endsWith(`/${windowType}`);
});
}
}
async getWindow(windowType: string = 'main'): Promise<Page | undefined> {
if (!this.app) return undefined;
// Special case for 'current' window
if (windowType === 'current') {
return this.currentWindow;
}
// 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;
}
}
}
setWorldConstructor(ApplicationWorld);
if (process.env.CI) {
setDefaultTimeout(50000);
}
AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) {
// Only take screenshots in CI environment
// if (!process.env.CI) return;
try {
// Prefer an existing currentWindow if it's still open
let pageToUse: Page | undefined;
if (this.currentWindow && !this.currentWindow.isClosed()) {
pageToUse = this.currentWindow;
}
// If currentWindow is not available, try to re-acquire any open window from the app
if ((!pageToUse || pageToUse.isClosed()) && this.app) {
const openPages = this.app.windows().filter(p => !p.isClosed());
if (openPages.length > 0) {
pageToUse = openPages[0];
this.currentWindow = pageToUse;
}
}
const scenarioName = pickle.name;
const cleanScenarioName = makeSlugPath(scenarioName);
const stepText = pickleStep.text;
const cleanStepText = makeSlugPath(stepText, 120);
const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status';
const featureDirectory = path.resolve(screenshotsDirectory, cleanScenarioName);
// Create directory asynchronously to avoid blocking the event loop in CI
await fs.ensureDir(featureDirectory);
// Sometimes window close and don't wait for use to take picture, or window haven't open in this step, never mind, just skip.
/**
* Typical steps like:
* - I add test ai settings
* - I cleanup test wiki so it could create a new one on start
* - I clear test ai settings
*/
if (!pageToUse || pageToUse.isClosed()) {
// console.warn(`Skipping screenshot: ${cleanStepText}`);
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const screenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}.jpg`);
// Use conservative screenshot options for CI
await pageToUse.screenshot({ path: screenshotPath, fullPage: true, type: 'jpeg', quality: 10 });
} catch (screenshotError) {
console.warn('Failed to take screenshot:', screenshotError);
}
});
When('I launch the TidGi application', async function(this: ApplicationWorld) {
// For E2E tests on dev mode, use the packaged test version with NODE_ENV environment variable baked in
const packedAppPath = getPackedAppPath();
try {
this.app = await electron.launch({
executablePath: packedAppPath,
// Add debugging options to prevent app from closing and CI-specific args
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-features=TranslateUI',
'--disable-ipc-flooding-protection',
'--force-device-scale-factor=1',
'--high-dpi-support=1',
'--force-color-profile=srgb',
'--disable-extensions',
'--disable-plugins',
'--disable-default-apps',
'--virtual-time-budget=1000',
'--run-all-compositor-stages-before-draw',
'--disable-checker-imaging',
// Linux CI specific arguments
...(process.env.CI && process.platform === 'linux'
? [
'--disable-background-mode',
'--disable-features=VizDisplayCompositor',
'--use-gl=swiftshader',
'--disable-accelerated-2d-canvas',
'--disable-accelerated-jpeg-decoding',
'--disable-accelerated-mjpeg-decode',
'--disable-accelerated-video-decode',
]
: []),
],
env: {
...process.env,
NODE_ENV: 'test',
E2E_TEST: 'true',
// Ensure tests run in Chinese locale so i18n UI strings match expectations
// set multiple variables for cross-platform coverage
LANG: process.env.LANG || 'zh-Hans.UTF-8',
LANGUAGE: process.env.LANGUAGE || 'zh-Hans:zh',
LC_ALL: process.env.LC_ALL || 'zh-Hans.UTF-8',
// Force display settings for CI
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
...(process.env.CI && {
ELECTRON_ENABLE_LOGGING: 'true',
ELECTRON_DISABLE_HARDWARE_ACCELERATION: 'true',
}),
},
timeout: 30000, // Increase timeout to 30 seconds for CI
});
// Wait longer for window in CI environment
const windowTimeout = process.env.CI ? 45000 : 10000;
this.mainWindow = await this.app.firstWindow({ timeout: windowTimeout });
this.currentWindow = this.mainWindow;
} catch (error) {
throw new Error(
`Failed to launch TidGi application: ${error as Error}. You should run \`pnpm run test:prepare-e2e\` before running the tests to ensure the app is built, and build with binaries like "dugite" and "tiddlywiki", see scripts/afterPack.js for more details.`,
);
}
});