mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-05-12 15:27:59 -07:00
* 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.
592 lines
20 KiB
TypeScript
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);
|
|
});
|