TidGi-Desktop/features/stepDefinitions/browserView.ts
lin onetwo ce332374bc
Fix/misc bug2 (#698)
* fix: hide non-wiki workspaces from menu and disable remove for them (#694)

* fix(workspace): make workspace settings save transactional to prevent data loss

- Move disk write before memory update in Workspace.set()
- Remove error swallowing in writeTidgiConfig() to let errors propagate
- Add error handling in useForm.ts to catch and log save failures
- Add UI error display in EditWorkspace/index.tsx
- Only update Observable after successful persistence

This fixes the issue where save button disappears but changes aren't
persisted to tidgi.config.json, causing data loss when reopening settings.

* feat(i18n): add error messages for workspace save failures

Add SaveError and SaveErrorPrefix translations in English and Chinese
to display error messages when workspace settings fail to save.

* test(e2e): add test for tagNames persistence and missing step definitions

- Add @edit-workspace-save-tagnames scenario to verify tagNames persist
  after save to tidgi.config.json
- Add 'I clear and type' step definition for clearing input before typing
- Add 'I close current window' step definition for closing windows
- This test covers the regression where save button disappears but
  changes aren't persisted

* use 5.4.0

* fix: spaced file in git op

* fix: menu register race condition

* Update pnpm-lock.yaml

* Update wiki

* fix: regenerate lockfile with pnpm 10.33.0 to fix checksum format

* fix: remove unused import and useless constructor

* fix: address Copilot review comments

- Use new path for rename/copy in git operations
- Ensure transactional workspace save (persist before cache update)
- Normalize null label to undefined in menu

* Remove close-window step; simplify UI typing

Remove the Cucumber step that closed the current window and simplify a UI step by calling locator.fill(...) inline (also replace {tmpDir} in the input). Clean up minor whitespace in gitOperations and fix indentation/extra brace around startWiki error handling in the wiki service to correct control flow and prevent accidental scope issues.

* Improve workspace form, git diff, and UI tests

Refactor EditWorkspace form and UI behavior, make git diff/status handling more robust, and update E2E tests.

- Add hasConfigChanges and related effects in useForm to correctly detect config-only changes and control restart requests; fix save button visibility in EditWorkspace and pass currentWorkspace to restart snackbar. Rename workspace section test id from 'workspace-section-search' to 'preference-section-search'.
- Enhance gitOperations.getFileDiff to use porcelain -z and a helper to parse per-path status (getPorcelainStatusForPath) for reliable untracked/deleted detection.
- Add clickBrowserViewElementWithRetry helper with backoff and text-aware selector handling; replace repetitive click logic in browser view step definitions and remove some redundant browser background assertions and a deprecated clear-and-type step.
- Update feature files (gitLog, editWorkspace, vectorSearch) to reflect selector/id/name changes and i18n fallbacks for tab/button text.

* doc

* Use localized draft selector and show e2e window

Update feature tests to target the localized draft tiddler title (data-tiddler-title$='的草稿') instead of the English prefix selector. Applied the change across features/hibernation.feature and features/tiddler.feature to ensure selectors match localized UI. Also enable SHOW_E2E_WINDOW=1 in the test:manual-e2e script in package.json so manual end-to-end runs open a visible window for debugging.
2026-04-21 22:14:43 +08:00

592 lines
20 KiB
TypeScript

import { DataTable, Then, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import { parseDataTableRows } from '../supports/dataTable';
import { HEAVY_OPERATION_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;
type BrowserViewBackgroundMode = 'dark' | 'light';
function parseRgbColor(backgroundColor: string): { red: number; green: number; blue: number } | undefined {
const match = backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!match) {
return undefined;
}
return {
red: Number(match[1]),
green: Number(match[2]),
blue: Number(match[3]),
};
}
async function assertBrowserViewBodyBackground(
world: ApplicationWorld,
expectedMode: BrowserViewBackgroundMode,
): Promise<void> {
if (!world.app) {
throw new Error('Application not launched');
}
const colorInfo = await executeTiddlyWikiCode<{ backgroundColor: string; paletteTitle: string }>(
world.app,
`(function() {
const backgroundColor = window.getComputedStyle(document.body).backgroundColor || '';
let paletteTitle = '';
try {
if (typeof $tw !== 'undefined' && $tw.wiki) {
paletteTitle = $tw.wiki.getTiddlerText('$:/palette', '') || '';
}
} catch {
paletteTitle = '';
}
return { backgroundColor, paletteTitle };
})()`,
world.currentWindow,
200,
);
if (!colorInfo) {
throw new Error('Failed to read browser view background color');
}
const rgb = parseRgbColor(colorInfo.backgroundColor);
if (!rgb) {
throw new Error(`Unexpected background color format: ${colorInfo.backgroundColor}; palette=${colorInfo.paletteTitle}`);
}
const { red, green, blue } = rgb;
const isDark = red < 128 && green < 128 && blue < 128;
const isLight = red > 200 && green > 200 && blue > 200;
if (expectedMode === 'dark' && !isDark) {
throw new Error(`Expected dark background in browser view, got ${colorInfo.backgroundColor}; palette=${colorInfo.paletteTitle}`);
}
if (expectedMode === 'light' && !isLight) {
throw new Error(`Expected light background in browser view, got ${colorInfo.backgroundColor}; palette=${colorInfo.paletteTitle}`);
}
}
async function clickBrowserViewElementWithRetry(
world: ApplicationWorld,
selector: string,
elementComment: string,
): Promise<void> {
if (!world.app) {
throw new Error('Application not launched');
}
await backOff(
async () => {
const exists = await elementExists(world.app!, selector, world.currentWindow);
if (!exists) {
throw new Error(`Element "${elementComment}" with selector "${selector}" not found yet`);
}
const hasTextMatch = selector.match(/^(.+):has-text\(['"](.+)['"]\)$/);
if (hasTextMatch) {
const baseSelector = hasTextMatch[1];
const textContent = hasTextMatch[2];
await clickElementWithText(world.app!, baseSelector, textContent, world.currentWindow);
} else {
await clickElement(world.app!, selector, world.currentWindow);
}
},
{ ...BACKOFF_OPTIONS, numOfAttempts: 20, startingDelay: 200, maxDelay: 200 },
).catch((error: unknown) => {
throw new Error(`Failed to click ${elementComment} element with selector "${selector}" in browser view: ${String(error)}`);
});
}
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!, this.currentWindow);
if (!content || !content.includes(expectedText)) {
throw new Error(`Expected text "${expectedText}" not found`);
}
},
BACKOFF_OPTIONS,
).catch(async () => {
const finalContent = await getTextContent(this.app!, this.currentWindow);
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!, this.currentWindow);
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!, this.currentWindow);
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');
}
// Use a longer retry window because WebContentsView creation is async and can take
// several seconds after workspace activation. Each attempt is fast (< 10ms) when the
// view doesn't exist yet, so we need many more attempts to cover the full wait budget.
const longRetryAttempts = Math.floor((HEAVY_OPERATION_TIMEOUT - 4000) / BROWSER_VIEW_RETRY_DELAY_MS);
await backOff(
async () => {
const content = await getTextContent(this.app!, this.currentWindow);
if (!content || content.trim().length === 0) {
throw new Error('Browser view content not available yet');
}
},
{
...BACKOFF_OPTIONS,
numOfAttempts: longRetryAttempts,
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!, this.currentWindow);
const content = await getTextContent(this.app!, this.currentWindow);
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 ${longRetryAttempts} attempts ` +
`(budget: ${Math.round(HEAVY_OPERATION_TIMEOUT / 1000)}s). ${diagnostics}`,
);
});
});
When('I click on {string} element in browser view with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
await clickBrowserViewElementWithRetry(this, selector, elementComment);
});
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 {
await clickBrowserViewElementWithRetry(this, selector, elementComment);
} 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, this.currentWindow);
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, this.currentWindow);
} 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, this.currentWindow);
} 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, this.currentWindow);
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, this.currentWindow);
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, this.currentWindow);
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, this.currentWindow);
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;
})()`,
this.currentWindow,
);
},
{ ...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, this.currentWindow);
// 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, this.currentWindow);
// 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, this.currentWindow);
} 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) || '',
};
})()`,
this.currentWindow,
200,
);
lastDiagnostic = JSON.stringify(diagnostic);
isImageLoaded = Boolean(diagnostic?.loaded);
} catch {
isImageLoaded = false;
}
if (!isImageLoaded) {
throw new Error(`Image ${imageName} is not loaded yet`);
}
},
{ numOfAttempts: 100, startingDelay: 150, timeMultiple: 1, maxDelay: 150 },
).catch(() => {
throw new Error(`Image ${imageName} is not loaded correctly in browser view. Last diagnostic: ${lastDiagnostic}`);
});
});
Then('browser view body background should be {string}', async function(this: ApplicationWorld, mode: string) {
if (mode !== 'dark' && mode !== 'light') {
throw new Error(`Unsupported browser view background mode: ${mode}. Use "dark" or "light".`);
}
await assertBrowserViewBodyBackground(this, mode);
});