mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-03-26 00:32:09 -07:00
* refactor: replace time-window echo prevention with mtime+size and content checks FileSystemWatcher: use recorded mtime+size (lastWriteStats) and saving-state flag (titlesBeingSaved) to skip own-write echoes, plus content identity fallback. Removes unreliable setTimeout-based exclude/scheduleFileInclusion. ipcServerRoutes: replace TTL Map with synchronous Set (ipcPendingTitles), attach changeCount as revision to change Observable. ipc-syncadaptor: use lastSavedRevisions for revision-based echo prevention, batch large syncs via requestIdleCallback. Also: EditWorkspace controlled input fixes, port field local state, agent framework config setConfig/persistConfig split, misc value ?? '' fixes. * Enhance e2e screenshots and test logging Improve end-to-end test reliability and diagnostics: - Capture window screenshots for non-BrowserView steps: add captureWindowScreenshot and use it in AfterStep to capture the Electron BrowserWindow via webContents.capturePage() when appropriate; keep existing captureScreenshot for BrowserView steps. (features/supports/webContentsViewHelper.ts, features/stepDefinitions/application.ts) - Prevent wiki restart race: wait for the wiki worker '[test-id-WIKI_WORKER_STARTED]' marker before restarting to avoid DoubleWikiInstanceError. (features/stepDefinitions/wiki.ts) - Make git-related test markers more visible by switching logger.debug → logger.info for git init/commit/sync/checkout/revert and git-log rendering markers, ensuring e2e tests can reliably detect these events. (src/services/git/index.ts, src/windows/GitLog/useGitLogData.ts) - Minor feature test tweak: wait for page to load in defaultWiki.feature before interacting with the editWorkspace window. These changes reduce flaky screenshots and timing races in tests and improve test marker visibility for e2e detection. * Enhance e2e screenshots and test logging Improve end-to-end test reliability and diagnostics: - Capture window screenshots for non-BrowserView steps: add captureWindowScreenshot and use it in AfterStep to capture the Electron BrowserWindow via webContents.capturePage() when appropriate; keep existing captureScreenshot for BrowserView steps. (features/supports/webContentsViewHelper.ts, features/stepDefinitions/application.ts) - Prevent wiki restart race: wait for the wiki worker '[test-id-WIKI_WORKER_STARTED]' marker before restarting to avoid DoubleWikiInstanceError. (features/stepDefinitions/wiki.ts) - Make git-related test markers more visible by switching logger.debug → logger.info for git init/commit/sync/checkout/revert and git-log rendering markers, ensuring e2e tests can reliably detect these events. (src/services/git/index.ts, src/windows/GitLog/useGitLogData.ts) - Minor feature test tweak: wait for page to load in defaultWiki.feature before interacting with the editWorkspace window. These changes reduce flaky screenshots and timing races in tests and improve test marker visibility for e2e detection. * Use startTransition to update port on change Import startTransition from React and update the port input handler to capture the raw value, update local display state, and defer parsing/setting the workspace port inside startTransition. The handler now parses an empty value as 0, validates the number (non-NaN and >= 0) before calling workspaceSetter, improving responsiveness by marking the workspace update as non-urgent. Also removed an obsolete inline comment. * Improve E2E, view sync, and wiki IPC robustness Multiple fixes and improvements across E2E tests, view management, wiki IPC, and workspace handling: - Docs: add SHOW_E2E_WINDOW env var note to allow visible Electron windows during manual E2E runs. - Features: simplify/adjust cross-window sync scenario and refine hibernation workspace selectors to avoid collisions with similarly named workspaces. - E2E steps: make AI-request assertion resilient by polling (backOff); replace brittle DOM-driven tiddler creation with direct TiddlyWiki API calls in browserView step definitions for reliability; add small UI click pause and longer click timeout in ui steps. - Timeouts: unify global timeout to 25s and derive Playwright short/log wait timeouts from it. - WebContents helpers: prefer the last child wiki view (active one) when multiple views exist and update comments. - WikiEmbedTabContent: wake workspace on mount and simplify cleanup to always clear custom bounds on unmount; handle errors. - Chat UI: hide TabListDropdown in split view. - Agent instances: default missing agentFrameworkID to 'basicPromptConcatHandler' for older definitions. - View service: add activelyShownViews set to avoid hiding views that were explicitly shown via showView(); clear stale custom bounds when showing views; avoid moving views offscreen when actively shown; ensure cleanup removes active flag. - IPC server routes: prefer URL-based workspace ID (resolve correct casing via workspace service) to handle cross-session routing; pass effective workspace ID to route handlers. - Wiki service: add startup timeout to avoid indefinite hangs waiting for worker boot message. - IPC sync adaptor: implement titlesBeingSaved/titlesBeingLoaded sets to prevent save-back and SSE echo issues; mark titles when saving/loading and suppress spurious saves. - Wiki worker IPC routes: replace per-subscriber addEventListener with a shared Subject and single change listener to avoid missed events and cross-window sync bugs; forward subject events to subscribers and log readiness. - Windows: keep windows hidden during tests by default but allow showing them when SHOW_E2E_WINDOW=1. - Workspaces: compute next insert order so new wiki workspaces appear at the top of regular workspaces (shift others down). Overall these changes reduce flakiness in tests, prevent cross-window echo and routing bugs, make view lifecycle handling safer, and improve developer ergonomics for debugging E2E runs. * fix(e2e): bypass system proxy for git HTTP operations in sync test Git clone/push/fetch to localhost was routed through the system proxy (port 1080), which returned 502 Bad Gateway. Add -c http.proxy= to disable proxy for all HTTP git commands in the sync test step definitions. * fix: address CI lint errors and Copilot review comments Lint fixes: - useOptimisticField: rename *Ref/*Fn vars to *Reference/*Function (unicorn/prevent-abbreviations) - wiki/index: rename args to arguments_ (unicorn/prevent-abbreviations) - FileSystemWatcher: use specific type assertion for tiddler fields to avoid no-base-to-string warning - webContentsViewHelper: rename loop var i to index - interface.ts: merge duplicate imports from same module (dprint) - ipc-syncadaptor: expand queueMicrotask callback (dprint) Copilot review fixes: - ipcServerRoutes: move subscription to outer scope so Observable teardown is returned synchronously from the constructor, preventing subscription leaks when observers are disposed before the async IIFE resolves - useOptimisticField: capture localValue at focus time; on blur only commit when user actually changed the value (not when serverValue updated while focused) - FileSystemWatcher.markSaveComplete: guard scheduleGitNotification behind non-empty absoluteFilePath to avoid spurious git notifications on error paths * fix(e2e): handle corrupt settings.json in cleanup and use filechooser intercept for image upload - tidgiMiniWindow cleanup: wrap readJson with try/catch so truncated/empty settings.json (race between app shutdown write and After hook read) is handled gracefully instead of throwing SyntaxError - application.ts: add 'I prepare to select file ... for file chooser' step that registers a Playwright one-shot filechooser handler BEFORE the click that triggers fileInput.click(). This prevents the native OS dialog from appearing entirely (the chooser is resolved directly with the supplied file). - talkWithAI.feature: replace two-step 'click then setInputFiles' with the new 'prepare filechooser click' pattern so no OS dialog is shown during the AI image attachment test
441 lines
17 KiB
TypeScript
441 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, captureWindowScreenshot } 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;
|
|
appLaunchPromise: Promise<void> | 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);
|
|
|
|
async function launchTidGiApplication(world: ApplicationWorld): Promise<void> {
|
|
const packedAppPath = getPackedAppPath();
|
|
|
|
world.app = await electron.launch({
|
|
executablePath: packedAppPath,
|
|
args: [
|
|
`--test-scenario=${world.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',
|
|
...(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',
|
|
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',
|
|
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
|
|
...(process.env.CI && {
|
|
ELECTRON_ENABLE_LOGGING: 'true',
|
|
ELECTRON_DISABLE_HARDWARE_ACCELERATION: 'true',
|
|
}),
|
|
},
|
|
cwd: process.cwd(),
|
|
timeout: PLAYWRIGHT_TIMEOUT,
|
|
});
|
|
|
|
// Do not block launch step on firstWindow; this can exceed Cucumber's 5s step timeout.
|
|
// Window acquisition is handled in "I wait for the page to load completely".
|
|
const openedWindows = world.app.windows().filter(page => !page.isClosed());
|
|
world.mainWindow = openedWindows[0];
|
|
world.currentWindow = world.mainWindow;
|
|
}
|
|
|
|
async function closeTidGiApplication(world: ApplicationWorld): Promise<void> {
|
|
// If launch is still in progress, wait it settle before closing.
|
|
if (world.appLaunchPromise) {
|
|
try {
|
|
await world.appLaunchPromise;
|
|
} catch {
|
|
// Ignore launch failure here; close path will clear world state.
|
|
}
|
|
}
|
|
|
|
if (!world.app) return;
|
|
|
|
try {
|
|
await Promise.race([
|
|
world.app.close(),
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => {
|
|
reject(new Error('close timeout'));
|
|
}, 4000)
|
|
),
|
|
]);
|
|
} catch {
|
|
try {
|
|
await Promise.race([
|
|
world.app.context().close({ reason: 'Relaunch application in scenario' }),
|
|
new Promise(resolve => setTimeout(resolve, 500)),
|
|
]);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
} finally {
|
|
world.appLaunchPromise = undefined;
|
|
world.app = undefined;
|
|
world.mainWindow = undefined;
|
|
world.currentWindow = undefined;
|
|
}
|
|
}
|
|
|
|
AfterStep({ timeout: 3000 }, async function(this: ApplicationWorld, { pickle, pickleStep, result }) {
|
|
if (!this.app) return;
|
|
|
|
try {
|
|
const stepText = pickleStep.text;
|
|
|
|
// Skip screenshots for steps that don't interact with the UI
|
|
if (stepText.match(/^I wait for|^I clear log|^I create file |^I sync |^I clone |^file "/i)) {
|
|
return;
|
|
}
|
|
|
|
const scenarioName = pickle.name;
|
|
const cleanScenarioName = makeSlugPath(scenarioName, 60);
|
|
const cleanStepText = makeSlugPath(stepText, 80);
|
|
const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status';
|
|
|
|
const scenarioScreenshotsDirectory = path.resolve(process.cwd(), 'test-artifacts', cleanScenarioName, 'userData-test', 'logs', 'screenshots');
|
|
await fs.ensureDir(scenarioScreenshotsDirectory);
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const screenshotPath = path.resolve(scenarioScreenshotsDirectory, `${timestamp}-${cleanStepText}-${stepStatus}.png`);
|
|
|
|
// Steps operating on BrowserView (WebContentsView) → capture the embedded wiki view
|
|
// Other steps (main window UI, editWorkspace, preferences, etc.) → capture the current window page
|
|
const isBrowserViewStep = /browser view|TiddlyWiki code/i.test(stepText);
|
|
if (isBrowserViewStep) {
|
|
await captureScreenshot(this.app, screenshotPath);
|
|
} else if (this.currentWindow && !this.currentWindow.isClosed()) {
|
|
await captureWindowScreenshot(this.app, this.currentWindow, screenshotPath);
|
|
}
|
|
} catch (error) {
|
|
// Screenshot is best-effort diagnostics, never fail a step for it
|
|
console.warn('[AfterStep screenshot]', error instanceof Error ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
// 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) {
|
|
this.appLaunchPromise = launchTidGiApplication(this).catch((error: unknown) => {
|
|
throw error;
|
|
});
|
|
});
|
|
|
|
When('I close the TidGi application', async function(this: ApplicationWorld) {
|
|
try {
|
|
await closeTidGiApplication(this);
|
|
} catch (error) {
|
|
throw new Error(`Failed to close TidGi application: ${error as Error}`);
|
|
}
|
|
});
|
|
|
|
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 prepare to select file {string} for file chooser', async function(this: ApplicationWorld, filePath: string) {
|
|
const page = this.currentWindow;
|
|
if (!page) {
|
|
throw new Error('No current window available');
|
|
}
|
|
const targetPath = path.resolve(process.cwd(), filePath);
|
|
if (!await fs.pathExists(targetPath)) {
|
|
throw new Error(`File does not exist: ${targetPath}`);
|
|
}
|
|
// Register a one-shot Playwright filechooser intercept BEFORE the click that
|
|
// triggers the file input. This prevents the native OS dialog from appearing
|
|
// and directly resolves the chooser with the supplied file.
|
|
page.once('filechooser', async (fileChooser) => {
|
|
await fileChooser.setFiles(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);
|
|
});
|