TidGi-Desktop/features/stepDefinitions/application.ts
lin onetwo a712b2ff51
Fix/misc bug (#679)
* Create ErrorDuringRelease.md

* Enforce test timeouts and add root tiddler scenario

Set global and step timeouts to 5s (local) and 10s (CI) across cucumber config and step definitions to standardize test execution times. Add a new scenario to verify root tiddler configuration and content loading after restart. Enhance start-e2e-app script to accept or auto-detect test scenario names and pass them to the app.

* Improve error handling for window and view initialization

Enhanced error reporting and handling when browser windows are not ready or fail to register in windowService. Updated focus logic to dynamically retrieve the current browser window, improving reliability during workspace hibernation and wake-up scenarios.

* Remove AfterAll hook and add --exit to e2e tests

Eliminates the AfterAll hook that forced process exit in cleanup.ts to prevent hanging after tests. Adds the --exit flag to the cucumber-js command in the e2e test script to ensure proper test process termination.

* Add step to restart workspace in wiki tests

Introduces a new step definition 'I restart workspace {string}' to programmatically restart a wiki workspace during tests. Updates the root tiddler scenario to use this step for verifying lazy-load behavior after workspace restart, improving test reliability and clarity.

* Centralize and standardize E2E test timeouts

Extracted timeout values into features/supports/timeouts.ts and replaced hardcoded timeouts in step definitions with named constants. This ensures consistent timeout handling across local and CI environments, reduces duplication, and clarifies intent. Also improved workspace update logic to check watch-fs state before restart and cleaned up related log marker handling.

* Improve i18n coverage and add Windows installer log access

Expanded and unified i18n keys for error messages and UI labels across multiple languages. Refactored code to remove hardcoded default values from translation calls. Added a Developer Tools option to open the Windows installer log folder (SquirrelTemp) when running on Windows. Introduced a placeholder file to preserve dynamic i18n keys for error messages.

* Initialize Tidgi mini window before workspace views

Moved the initialization of the Tidgi mini window to occur before initializing all workspace views in main.ts to ensure correct view creation. Added a clarifying comment in DeveloperTools.tsx regarding the SquirrelSetup.log path.

* Refactor Tidgi mini window initialization logic

Tidgi mini window creation now only creates the window; view creation is deferred to initializeAllWorkspaceView. Updated related comments and logging for clarity. Also fixed formatting in French translations and improved documentation for error handling during release.

* Add model feature chips to model selection UI

Introduces a ModelFeatureChip component to visually display model features in the model selector and new model dialog. Updates defaultProviders to include new models with features, and enhances the UI to show feature chips for each model, improving clarity for users selecting models.

* Add image attachment support to chat messages

This update enables users to attach image files to chat messages, including UI changes for file selection and preview, backend persistence of attachments, and prompt concatenation logic to include images in AI requests. It also adds error handling and i18n for model vision support, updates message rendering to display images, and improves logging and API validation for vision-capable models.

* Improve streaming status handling for agent messages

Adds a failsafe to clear streaming status when an agent reaches a terminal state and refines logic to prevent marking completed messages as streaming. Also updates message stream completion in AgentInstanceService to ensure proper cleanup and delivery of IPC messages. Includes new feature tests for message streaming status and image upload scenarios.

* Add cross-window sync feature and test steps

Introduces a new feature file for cross-window synchronization scenarios. Adds step definitions to open workspaces in new windows and execute TiddlyWiki code programmatically. Removes obsolete wiki.ts.backup file and updates agentActions for related actions.

* feat(sync): fix cross-window synchronization via SSE

- Remove overly aggressive echo prevention in backend that blocked all SSE updates
- Backend now forwards all wiki change events to subscribers
- Add comprehensive cross-window sync tests verifying bidirectional updates
- Test main->new window sync and new->main window sync scenarios
- Version bump to 0.13.0-prerelease19

* Improve file attachment handling in chat and tests

Refactors file input handling in chat tests to use Playwright's setInputFiles, updates message sending types to support optional file attachments, and enhances file metadata persistence and logging. Adjusts test expectations and UI logic to better handle and display image attachments, and clarifies combobox value assertions in ExternalAPI tests.

* Add file input validation and improve i18n messages

Added image type and size validation (10MB limit) to file input in InputContainer. Improved image preview logic. Updated French, Japanese, and Russian translations with new error messages for missing/default model and vision support. Enhanced type safety in promptConcatWithImage tests and messagePersistence logging. Fixed race condition in ExternalAPIService lazy initialization. Updated CommitDetailsPanel to use common cancel translation key.

* review

* Update browserView.ts

* Update timeouts.ts

* Update cucumber.config.js

* Update cucumber.config.js

* Move global timeout config to separate module

Extracted global timeout setup from cucumber.config.js to features/supports/timeout-config.ts using setDefaultTimeout. This ensures the timeout is set via code rather than config, improving clarity and maintainability.

* Update newAgent.feature
2026-01-26 02:43:27 +08:00

419 lines
17 KiB
TypeScript

import { AfterStep, 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 { getPackedAppPath, makeSlugPath } from '../supports/paths';
import { PLAYWRIGHT_TIMEOUT } from '../supports/timeouts';
import { captureScreenshot } from '../supports/webContentsViewHelper';
/**
* ═══════════════════════════════════════════════════════════════════════════
* ⚠️ CRITICAL WARNING FOR ALL AI AGENTS - READ THIS BEFORE ANY MODIFICATION ⚠️
* ═══════════════════════════════════════════════════════════════════════════
*
* ABSOLUTE RULES - NO EXCEPTIONS:
*
* 1. NEVER INCREASE TIMEOUT VALUES! TIMEOUT = FAILURE = REAL BUG!
* - Timeout is a SYMPTOM, not the disease
* - Fix the ROOT CAUSE in application code, not the timeout
*
* 2. MAXIMUM TIMEOUTS (STRICTLY ENFORCED):
* - Local: 5 seconds
* - CI: 10 seconds (exactly 2x local, NO MORE)
*
* 3. BEFORE MODIFYING ANY TIMEOUT:
* - STOP! Read docs/Testing.md completely
* - Investigate test-artifacts/{scenarioSlug}/userData-test/logs/
* - Find the REAL BUG (SQLite errors, missing elements, failed loads)
* - Fix the APPLICATION CODE, not the test
*
* 4. THIS HAS BEEN VIOLATED 3 TIMES - DO NOT MAKE IT 4!
*
* Per docs/Testing.md: "Timeout usually because of expected element not present."
* The test is waiting for something that will NEVER happen due to a BUG.
*
* ═══════════════════════════════════════════════════════════════════════════
*/
// 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;
savedWorkspaceId: string | undefined; // For storing workspace ID between steps
scenarioName: string = 'default'; // Scenario name from Cucumber pickle
scenarioSlug: string = 'default'; // Sanitized scenario name for file paths
providerConfig: import('@services/externalAPI/interface').AIProviderConfig | undefined; // Scenario-specific AI provider config
// 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);
AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) {
// Only take screenshots in CI environment
// if (!process.env.CI) return;
try {
const stepText = pickleStep.text;
// Skip screenshots for wait steps to avoid too many screenshots
if (stepText.match(/^I wait for/i)) {
return;
}
if (stepText.match(/^I clear log/i)) {
return;
}
// 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];
}
}
const scenarioName = pickle.name;
// Limit scenario slug to avoid extremely long directory names
const cleanScenarioName = makeSlugPath(scenarioName, 60);
// Limit step text slug to avoid excessively long filenames which can trigger ENAMETOOLONG
const cleanStepText = makeSlugPath(stepText, 80);
const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status';
// Use scenario-specific screenshots directory
const scenarioScreenshotsDirectory = path.resolve(process.cwd(), 'test-artifacts', cleanScenarioName, 'userData-test', 'logs', 'screenshots');
// Create directory asynchronously to avoid blocking the event loop in CI
await fs.ensureDir(scenarioScreenshotsDirectory);
// 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, '-');
// Try to capture both WebContentsView and Page screenshots
let webViewCaptured = false;
if (this.app) {
const webViewScreenshotPath = path.resolve(scenarioScreenshotsDirectory, `${timestamp}-${cleanStepText}-${stepStatus}-webview.png`);
webViewCaptured = await captureScreenshot(this.app, webViewScreenshotPath);
}
// Always capture page screenshot (UI chrome/window)
const pageScreenshotPath = path.resolve(scenarioScreenshotsDirectory, `${timestamp}-${cleanStepText}-${stepStatus}${webViewCaptured ? '-page' : ''}.png`);
await pageToUse.screenshot({ path: pageScreenshotPath, fullPage: true, type: 'png' });
} catch (screenshotError) {
console.warn('Failed to take screenshot:', screenshotError);
}
});
// CRITICAL WARNING FOR ALL AI AGENTS:
// DO NOT MODIFY TIMEOUT VALUES! NEVER!
// If a test times out, it means there is a REAL BUG to fix, not a timeout to increase.
// Timeout is a symptom, not the disease. Fix the root cause.
// Read docs/Testing.md section "Key E2E Testing Patterns" point 6 before attempting any changes.
// Maximum allowed timeouts: Local 5s, CI 10s (exactly 2x local, no more)
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: [
// Pass slugified scenario name to application for exact path isolation matching
// This ensures consistency with the slug generated in appPaths.ts
`--test-scenario=${this.scenarioSlug}`,
'--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',
}),
},
// Set cwd to repo root; scenario isolation is handled via --test-scenario argument
cwd: process.cwd(),
timeout: PLAYWRIGHT_TIMEOUT,
});
this.mainWindow = await this.app.firstWindow({ timeout: PLAYWRIGHT_TIMEOUT });
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.`,
);
}
});
When('I prepare to select directory in dialog {string}', async function(this: ApplicationWorld, directoryName: string) {
if (!this.app) {
throw new Error('Application is not launched');
}
// Use scenario-specific path for isolation
const targetPath = path.resolve(process.cwd(), 'test-artifacts', this.scenarioSlug, directoryName);
// Ensure parent directory exists (but do NOT remove target directory - it may be an existing wiki we want to import)
await fs.ensureDir(path.dirname(targetPath));
// Setup one-time dialog handler that restores after use
await this.app.evaluate(({ dialog }, targetDirectory: string) => {
// Save original function with proper binding
const originalShowOpenDialog = dialog.showOpenDialog.bind(dialog);
// Override with one-time mock
dialog.showOpenDialog = async () => {
// Restore original immediately after first call
dialog.showOpenDialog = originalShowOpenDialog;
return {
canceled: false,
filePaths: [targetDirectory],
};
};
}, targetPath);
});
When('I set file {string} to file input with selector {string}', async function(this: ApplicationWorld, filePath: string, selector: string) {
const page = this.currentWindow;
if (!page) {
throw new Error('No current window available');
}
// Resolve the file path relative to project root
const targetPath = path.resolve(process.cwd(), filePath);
// Verify the file exists
if (!await fs.pathExists(targetPath)) {
throw new Error(`File does not exist: ${targetPath}`);
}
// Use Playwright's setInputFiles to directly set file to the input element
// This works even for hidden inputs
await page.locator(selector).setInputFiles(targetPath);
});