TidGi-Desktop/features/stepDefinitions/browserView.ts
lin onetwo 2a5eb349f2
Fix/misc bug (#686)
* 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
2026-03-12 21:19:10 +08:00

522 lines
18 KiB
TypeScript

import { DataTable, Then, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import { parseDataTableRows } from '../supports/dataTable';
import { CUCUMBER_GLOBAL_TIMEOUT } from '../supports/timeouts';
import {
clickElement,
clickElementWithText,
elementExists,
executeTiddlyWikiCode,
getDOMContent,
getTextContent,
isLoaded,
pressKey,
typeText,
} from '../supports/webContentsViewHelper';
import type { ApplicationWorld } from './application';
// Backoff configuration for retries
const BACKOFF_OPTIONS = {
numOfAttempts: 8,
startingDelay: 200,
timeMultiple: 1,
maxDelay: 200,
};
const BROWSER_VIEW_RETRY_DELAY_MS = 100;
/**
* Each retry iteration takes roughly BROWSER_VIEW_RETRY_DELAY_MS (backoff delay)
* PLUS the executeInBrowserView timeout (~2000ms worst case for heavy TiddlyWiki pages).
* Account for both when calculating how many attempts fit within the Cucumber step
* timeout budget, leaving 4s margin for catch-block diagnostics and Cucumber overhead.
*/
const ESTIMATED_PER_ATTEMPT_MS = BROWSER_VIEW_RETRY_DELAY_MS + 2000; // delay + executeJavaScript timeout
const BROWSER_VIEW_RETRY_ATTEMPTS = Math.max(
8,
Math.floor((CUCUMBER_GLOBAL_TIMEOUT - 4000) / ESTIMATED_PER_ATTEMPT_MS),
);
Then('I should see {string} in the browser view content', async function(this: ApplicationWorld, expectedText: string) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
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) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
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) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
await backOff(
async () => {
const content = await getTextContent(this.app!);
if (!content || content.trim().length === 0) {
throw new Error('Browser view content not available yet');
}
},
{
...BACKOFF_OPTIONS,
numOfAttempts: BROWSER_VIEW_RETRY_ATTEMPTS,
startingDelay: BROWSER_VIEW_RETRY_DELAY_MS,
maxDelay: BROWSER_VIEW_RETRY_DELAY_MS,
},
).catch(async () => {
// Gather diagnostics for failure analysis
let diagnostics = '';
try {
const loaded = await isLoaded(this.app!);
const content = await getTextContent(this.app!);
diagnostics = `isLoaded=${loaded}, textContent=${content ? `"${content.substring(0, 100)}..."` : 'null'}`;
} catch (diagError) {
diagnostics = `diagnostics failed: ${String(diagError)}`;
}
throw new Error(
`Browser view is not loaded or visible after ${BROWSER_VIEW_RETRY_ATTEMPTS} attempts ` +
`(~${Math.round((BROWSER_VIEW_RETRY_ATTEMPTS * ESTIMATED_PER_ATTEMPT_MS) / 1000)}s / ${Math.round(CUCUMBER_GLOBAL_TIMEOUT / 1000)}s budget). ${diagnostics}`,
);
});
});
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');
}
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 click on {string} elements in browser view with selectors:', async function(this: ApplicationWorld, _elementDescriptions: string, dataTable: DataTable) {
if (!this.app) {
throw new Error('Application not launched');
}
const rows = dataTable.raw();
const dataRows = parseDataTableRows(rows, 2);
const errors: string[] = [];
if (dataRows[0]?.length !== 2) {
throw new Error('Table must have exactly 2 columns: | element description | selector |');
}
for (const [elementComment, selector] of dataRows) {
try {
const hasTextMatch = selector.match(/^(.+):has-text\(['"](.+)['"]\)$/);
if (hasTextMatch) {
const baseSelector = hasTextMatch[1];
const textContent = hasTextMatch[2];
await clickElementWithText(this.app, baseSelector, textContent);
} else {
await clickElement(this.app, selector);
}
} catch (error) {
errors.push(`Failed to click ${elementComment} element with selector "${selector}" in browser view: ${error as Error}`);
}
}
if (errors.length > 0) {
throw new Error(`Failed to click elements in browser view:\n${errors.join('\n')}`);
}
});
Then('I wait for {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');
}
await backOff(
async () => {
const exists = await elementExists(this.app!, selector);
if (!exists) {
throw new Error(`Element "${elementComment}" with selector "${selector}" not found yet`);
}
},
{ ...BACKOFF_OPTIONS, numOfAttempts: 20, startingDelay: 200 },
).catch(() => {
throw new Error(`Element "${elementComment}" with selector "${selector}" did not appear in browser view after multiple attempts`);
});
});
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`);
});
});
Then('I should see {string} elements in browser view with selectors:', async function(this: ApplicationWorld, _elementDescriptions: string, dataTable: DataTable) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
const rows = dataTable.raw();
const dataRows = parseDataTableRows(rows, 2);
const errors: string[] = [];
if (dataRows[0]?.length !== 2) {
throw new Error('Table must have exactly 2 columns: | element description | selector |');
}
await Promise.all(dataRows.map(async ([elementComment, selector]) => {
try {
await backOff(
async () => {
const exists: boolean = await elementExists(this.app!, selector);
if (!exists) {
throw new Error('Element does not exist yet');
}
},
BACKOFF_OPTIONS,
);
} catch (error) {
errors.push(`Element "${elementComment}" with selector "${selector}" not found in browser view: ${error as Error}`);
}
}));
if (errors.length > 0) {
throw new Error(`Failed to find elements in browser view:\n${errors.join('\n')}`);
}
});
When('I open tiddler {string} in browser view', async function(this: ApplicationWorld, tiddlerTitle: string) {
if (!this.app) {
throw new Error('Application not launched');
}
/**
* Use flat 200 ms retries instead of exponential back-off.
* During a wiki restart, executeTiddlyWikiCode hangs for ~200 ms per attempt
* (webContents navigating), then needs a delay before the next try.
* Flat 200 ms gives ~12 attempts in the 5 s Cucumber step budget, which is
* enough to bridge the gap when the wiki becomes ready late into the step.
*/
await backOff(
async () => {
await executeTiddlyWikiCode(
this.app!,
`(function() {
const title = "${tiddlerTitle.replace(/"/g, '\\"')}";
try { if ($tw?.wiki?.removeFromStory) $tw.wiki.removeFromStory(title); } catch {}
$tw.wiki.addToStory(title);
return true;
})()`,
);
},
{ ...BACKOFF_OPTIONS, numOfAttempts: 8, startingDelay: 200, timeMultiple: 1, maxDelay: 200 },
).catch((error: unknown) => {
throw new Error(`Failed to open tiddler "${tiddlerTitle}" in browser view: ${error as Error}`);
});
});
/**
* Create a new tiddler with title and optional tags via TiddlyWiki UI.
* This step handles all the UI interactions: click add button, set title, add tags, and confirm.
*/
When('I create a tiddler {string} with tag {string} in browser view', async function(
this: ApplicationWorld,
tiddlerTitle: string,
tagName: string,
) {
if (!this.app) {
throw new Error('Application not launched');
}
// Use TiddlyWiki API directly — the DOM-based approach (typeText / pressKey)
// doesn't reliably trigger TW5's custom input handling for the title editor.
const script = `(function() {
var now = $tw.utils.stringifyDate(new Date());
$tw.wiki.addTiddler(new $tw.Tiddler({
title: ${JSON.stringify(tiddlerTitle)},
tags: ${JSON.stringify(tagName)},
text: '',
type: 'text/vnd.tiddlywiki',
created: now,
modified: now
}));
return true;
})()`;
await executeTiddlyWikiCode(this.app, script);
// Give syncer time to pick up the change and write to disk
await new Promise(resolve => setTimeout(resolve, 1500));
});
/**
* Create a new tiddler with title and custom field via TiddlyWiki UI.
*/
When('I create a tiddler {string} with field {string} set to {string} in browser view', async function(
this: ApplicationWorld,
tiddlerTitle: string,
fieldName: string,
fieldValue: string,
) {
if (!this.app) {
throw new Error('Application not launched');
}
// Use TiddlyWiki API directly for reliability.
const script = `(function() {
var now = $tw.utils.stringifyDate(new Date());
var fields = {
title: ${JSON.stringify(tiddlerTitle)},
text: '',
type: 'text/vnd.tiddlywiki',
created: now,
modified: now
};
fields[${JSON.stringify(fieldName)}] = ${JSON.stringify(fieldValue)};
$tw.wiki.addTiddler(new $tw.Tiddler(fields));
return true;
})()`;
await executeTiddlyWikiCode(this.app, script);
// Give syncer time to pick up the change and write to disk
await new Promise(resolve => setTimeout(resolve, 1500));
});
/**
* Execute TiddlyWiki code in browser view
* Useful for programmatic wiki operations
*/
When('I execute TiddlyWiki code in browser view: {string}', async function(this: ApplicationWorld, code: string) {
if (!this.app) {
throw new Error('Application not launched');
}
try {
// Wrap the code to avoid returning non-serializable objects
const wrappedCode = `(function() { ${code}; return true; })()`;
await executeTiddlyWikiCode(this.app, wrappedCode);
} catch (error) {
throw new Error(`Failed to execute TiddlyWiki code in browser view: ${error as Error}`);
}
});
Then('image {string} should be loaded in browser view', async function(this: ApplicationWorld, imageName: string) {
if (!this.app) {
throw new Error('Application not launched');
}
const tiddlerTitle = imageName;
let lastDiagnostic = '';
await backOff(
async () => {
let isImageLoaded = false;
try {
const diagnostic = await executeTiddlyWikiCode<{
loaded: boolean;
hasContainer: boolean;
hasImage: boolean;
src: string;
complete: boolean;
naturalWidth: number;
naturalHeight: number;
canonicalUri: string;
}>(
this.app!,
`(function() {
const title = ${JSON.stringify(tiddlerTitle)};
const container = document.querySelector("[data-tiddler-title='" + title.replace(/'/g, "\\'") + "']");
if (!container) {
try { if (typeof $tw !== 'undefined' && $tw.wiki) $tw.wiki.addToStory(title); } catch {}
return {
loaded: false,
hasContainer: false,
hasImage: false,
src: '',
complete: false,
naturalWidth: 0,
naturalHeight: 0,
canonicalUri: (typeof $tw !== 'undefined' && $tw.wiki && $tw.wiki.getTiddler(title)?.fields?._canonical_uri) || '',
};
}
const image = container.querySelector('img');
if (!image) {
return {
loaded: false,
hasContainer: true,
hasImage: false,
src: '',
complete: false,
naturalWidth: 0,
naturalHeight: 0,
canonicalUri: (typeof $tw !== 'undefined' && $tw.wiki && $tw.wiki.getTiddler(title)?.fields?._canonical_uri) || '',
};
}
return {
loaded: Boolean(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0),
hasContainer: true,
hasImage: true,
src: image.currentSrc || image.src || '',
complete: Boolean(image.complete),
naturalWidth: Number(image.naturalWidth || 0),
naturalHeight: Number(image.naturalHeight || 0),
canonicalUri: (typeof $tw !== 'undefined' && $tw.wiki && $tw.wiki.getTiddler(title)?.fields?._canonical_uri) || '',
};
})()`,
200,
);
lastDiagnostic = JSON.stringify(diagnostic);
isImageLoaded = Boolean(diagnostic?.loaded);
} catch {
isImageLoaded = false;
}
if (!isImageLoaded) {
throw new Error(`Image ${imageName} is not loaded yet`);
}
},
{ numOfAttempts: 10, startingDelay: 150, timeMultiple: 1, maxDelay: 150 },
).catch(() => {
throw new Error(`Image ${imageName} is not loaded correctly in browser view. Last diagnostic: ${lastDiagnostic}`);
});
});