From 268d8977c63a5ea5a7080817b160b22c789a1c2a Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sat, 14 Feb 2026 19:10:55 +0000 Subject: [PATCH 1/2] fix(sonar): clear complexity smells and add locale screenshot gate --- scripts/capture-ui-screenshot.js | 70 ++++++- src/renderer/components/ConfigTab.tsx | 261 +++++++++++++++----------- src/renderer/feature-flags.ts | 4 +- src/renderer/i18n/index.ts | 4 +- src/renderer/i18n/settings.ts | 22 ++- tests/catalog.md | 8 +- 6 files changed, 244 insertions(+), 125 deletions(-) diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index c827476..32987f3 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -11,6 +11,8 @@ const DEFAULT_SCREENSHOT_DIR = path.join('dist', 'qa', 'screenshots'); const SCREENSHOT_DIR = resolveOutputDirectory(process.env.UI_SCREENSHOT_DIR); const PORT = Number(process.env.UI_SCREENSHOT_PORT || 4173); const DEFAULT_SCREENSHOT_NAME = `ui-${process.platform}-${process.arch}.png`; +const DEFAULT_LOCALE = 'en'; +const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de']; const FIXED_MTIME = 1700000000000; function loadSecretScannerHelpers() { @@ -335,6 +337,10 @@ const SCREENSHOT_PATH = resolveOutputPath(SCREENSHOT_NAME); const SCREENSHOTS = { configDefault: SCREENSHOT_PATH, + localeEn: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-en.png`), + localeEs: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-es.png`), + localeFr: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-fr.png`), + localeDe: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-de.png`), sourceTab: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-source.png`), sourceSelected: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-source-selected.png`), sourceSelectedResized: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-source-selected-resized.png`), @@ -343,6 +349,7 @@ const SCREENSHOTS = { const UI_SELECTORS = { appRoot: '#app', + languageSelector: '#language-selector', configTab: '[data-tab="config"]', sourceTab: '[data-tab="source"]', processedTabActive: '[data-tab="processed"][aria-selected="true"]', @@ -361,6 +368,13 @@ const UI_SELECTORS = { processedContent: '#processed-content', }; +const LOCALE_SCREENSHOT_KEYS = { + en: 'localeEn', + es: 'localeEs', + fr: 'localeFr', + de: 'localeDe', +}; + async function setupMockElectronApi(page) { await page.addInitScript( ({ mockRootPath, mockConfig, mockDirectoryTree, mockFilteredDirectoryTree, fixedMtime }) => { @@ -368,6 +382,7 @@ async function setupMockElectronApi(page) { sessionStorage.clear(); localStorage.setItem('rootPath', mockRootPath); localStorage.setItem('configContent', mockConfig); + localStorage.setItem('app.locale', 'en'); const cloneTree = (treeItems) => JSON.parse(JSON.stringify(treeItems)); const delay = (durationMs) => @@ -488,6 +503,45 @@ async function setupMockElectronApi(page) { ); } +async function setLocaleAndWait(page, locale) { + await page.selectOption(UI_SELECTORS.languageSelector, locale); + await page.waitForFunction( + ({ languageSelector, expectedLocale }) => { + const localeSelector = document.querySelector(languageSelector); + if (!(localeSelector instanceof HTMLSelectElement)) { + return false; + } + + return ( + localeSelector.value === expectedLocale && + localStorage.getItem('app.locale') === expectedLocale + ); + }, + { languageSelector: UI_SELECTORS.languageSelector, expectedLocale: locale } + ); +} + +async function captureLocaleScreenshots(page) { + await runStep('Wait for language selector', async () => { + await page.waitForSelector(UI_SELECTORS.languageSelector, { timeout: 10000 }); + }); + + for (const locale of SUPPORTED_LOCALES) { + await runStep(`Switch locale to ${locale}`, async () => { + await setLocaleAndWait(page, locale); + }); + + await runStep(`Capture locale screenshot (${locale})`, async () => { + const screenshotKey = LOCALE_SCREENSHOT_KEYS[locale]; + await page.screenshot({ path: SCREENSHOTS[screenshotKey], fullPage: true }); + }); + } + + await runStep(`Reset locale to ${DEFAULT_LOCALE}`, async () => { + await setLocaleAndWait(page, DEFAULT_LOCALE); + }); +} + async function captureAppStateScreenshots(page) { await runStep('Disable animations for stable screenshots', async () => { await page.addStyleTag({ @@ -514,6 +568,8 @@ async function captureAppStateScreenshots(page) { await page.screenshot({ path: SCREENSHOTS.configDefault, fullPage: true }); }); + await captureLocaleScreenshots(page); + await runStep('Switch to source tab', async () => { await page.click(UI_SELECTORS.sourceTab); }); @@ -703,7 +759,19 @@ async function captureScreenshot() { await page.goto(`http://127.0.0.1:${PORT}/index.html`, { waitUntil: 'networkidle' }); }); await captureAppStateScreenshots(page); - Object.values(SCREENSHOTS).forEach((screenshotPath) => { + const screenshotPaths = [ + SCREENSHOTS.configDefault, + SCREENSHOTS.localeEn, + SCREENSHOTS.localeEs, + SCREENSHOTS.localeFr, + SCREENSHOTS.localeDe, + SCREENSHOTS.sourceTab, + SCREENSHOTS.sourceSelected, + SCREENSHOTS.sourceSelectedResized, + SCREENSHOTS.processedTab, + ]; + + screenshotPaths.forEach((screenshotPath) => { console.log(`UI screenshot captured: ${screenshotPath}`); }); } finally { diff --git a/src/renderer/components/ConfigTab.tsx b/src/renderer/components/ConfigTab.tsx index 1cb1c10..2746c0f 100755 --- a/src/renderer/components/ConfigTab.tsx +++ b/src/renderer/components/ConfigTab.tsx @@ -141,49 +141,67 @@ type ConfigFormAction = | { type: 'SET_FIELD'; field: keyof ConfigFormState; value: ConfigFormState[keyof ConfigFormState] } | { type: 'LOAD_FROM_CONFIG'; config: ConfigObject; aiSurfacesEnabled: boolean }; +const toPlainTextList = (value: unknown): string => { + return Array.isArray(value) ? yamlArrayToPlainText(value) : ''; +}; + +const toTrimmedLines = (value: string): string[] => { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); +}; + +const extractProviderFormFields = ( + config: ConfigObject, + aiSurfacesEnabled: boolean +): Pick => { + if (!aiSurfacesEnabled || !config.provider) { + return { + providerId: '', + providerModel: '', + providerApiKey: '', + providerBaseUrl: '', + }; + } + + const providerConfig = config.provider; + return { + providerId: isSupportedProviderId(providerConfig.id) ? providerConfig.id : '', + providerModel: typeof providerConfig.model === 'string' ? providerConfig.model : '', + providerApiKey: typeof providerConfig.api_key === 'string' ? providerConfig.api_key : '', + providerBaseUrl: typeof providerConfig.base_url === 'string' ? providerConfig.base_url : '', + }; +}; + +const loadFormStateFromConfig = ( + state: ConfigFormState, + config: ConfigObject, + aiSurfacesEnabled: boolean +): ConfigFormState => { + const providerFields = extractProviderFormFields(config, aiSurfacesEnabled); + return { + ...state, + fileExtensions: toPlainTextList(config.include_extensions), + excludePatterns: toPlainTextList(config.exclude_patterns), + useCustomExcludes: config.use_custom_excludes !== false, + useCustomIncludes: config.use_custom_includes !== false, + useGitignore: config.use_gitignore !== false, + enableSecretScanning: config.enable_secret_scanning !== false, + excludeSuspiciousFiles: config.exclude_suspicious_files !== false, + includeTreeView: config.include_tree_view === true, + showTokenCount: config.show_token_count !== false, + exportFormat: normalizeExportFormat(config.export_format), + ...providerFields, + }; +}; + const configFormReducer = (state: ConfigFormState, action: ConfigFormAction): ConfigFormState => { switch (action.type) { case 'SET_FIELD': return { ...state, [action.field]: action.value }; - case 'LOAD_FROM_CONFIG': { - const config = action.config; - const providerConfig = action.aiSurfacesEnabled ? config?.provider ?? {} : {}; - return { - ...state, - fileExtensions: - config?.include_extensions && Array.isArray(config.include_extensions) - ? yamlArrayToPlainText(config.include_extensions) - : '', - excludePatterns: - config?.exclude_patterns && Array.isArray(config.exclude_patterns) - ? yamlArrayToPlainText(config.exclude_patterns) - : '', - useCustomExcludes: config?.use_custom_excludes !== false, - useCustomIncludes: config?.use_custom_includes !== false, - useGitignore: config?.use_gitignore !== false, - enableSecretScanning: config?.enable_secret_scanning !== false, - excludeSuspiciousFiles: config?.exclude_suspicious_files !== false, - includeTreeView: config?.include_tree_view === true, - showTokenCount: config?.show_token_count !== false, - exportFormat: normalizeExportFormat(config?.export_format), - providerId: - action.aiSurfacesEnabled && isSupportedProviderId(providerConfig.id) - ? providerConfig.id - : '', - providerModel: - action.aiSurfacesEnabled && typeof providerConfig.model === 'string' - ? providerConfig.model - : '', - providerApiKey: - action.aiSurfacesEnabled && typeof providerConfig.api_key === 'string' - ? providerConfig.api_key - : '', - providerBaseUrl: - action.aiSurfacesEnabled && typeof providerConfig.base_url === 'string' - ? providerConfig.base_url - : '', - }; - } + case 'LOAD_FROM_CONFIG': + return loadFormStateFromConfig(state, action.config, action.aiSurfacesEnabled); default: return state; } @@ -206,6 +224,80 @@ const initialFormState: ConfigFormState = { providerBaseUrl: '', }; +const parseConfigContent = (configContent: string): ConfigObject => { + try { + const parsedConfig = yaml.parse(configContent) as ConfigObject; + if (!parsedConfig || typeof parsedConfig !== 'object') { + return {}; + } + return parsedConfig; + } catch (error) { + console.error('Error parsing config content, using empty config:', error); + return {}; + } +}; + +const applyBaseConfigState = (config: ConfigObject, state: ConfigFormState): void => { + config.use_custom_excludes = state.useCustomExcludes; + config.use_custom_includes = state.useCustomIncludes; + config.use_gitignore = state.useGitignore; + config.enable_secret_scanning = state.enableSecretScanning; + config.exclude_suspicious_files = state.excludeSuspiciousFiles; + config.include_tree_view = state.includeTreeView; + config.show_token_count = state.showTokenCount; + config.export_format = state.exportFormat; + config.include_extensions = toTrimmedLines(state.fileExtensions); + config.exclude_patterns = toTrimmedLines(state.excludePatterns); +}; + +type ProviderConfigSaveResult = { + hasValidationErrors: boolean; + validationErrors: string[]; +}; + +const applyProviderConfigState = ( + config: ConfigObject, + state: ConfigFormState, + aiSurfacesEnabled: boolean, + translate: (key: string) => string +): ProviderConfigSaveResult => { + if (!aiSurfacesEnabled) { + return { hasValidationErrors: false, validationErrors: [] }; + } + + const providerFields = { + providerId: state.providerId, + providerModel: state.providerModel, + providerApiKey: state.providerApiKey, + providerBaseUrl: state.providerBaseUrl, + }; + const validationErrors = getProviderValidationErrors(providerFields, translate); + const hasValidationErrors = validationErrors.length > 0; + + if (hasValidationErrors) { + if (config.provider) { + delete config.provider; + } + return { hasValidationErrors, validationErrors }; + } + + if (hasProviderInput(providerFields) && state.providerId) { + config.provider = { + id: state.providerId, + model: state.providerModel.trim(), + api_key: trimToUndefined(state.providerApiKey), + base_url: trimToUndefined(state.providerBaseUrl), + }; + return { hasValidationErrors, validationErrors }; + } + + if (config.provider) { + delete config.provider; + } + + return { hasValidationErrors, validationErrors }; +}; + const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const { t } = useTranslation(); const { rootPath, selectDirectory, switchTab } = useApp(); @@ -237,71 +329,17 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const saveConfig = useCallback( (state: ConfigFormState) => { try { - let config: ConfigObject; - - try { - config = yaml.parse(configContent) as ConfigObject; - if (!config) { - config = {}; - } - } catch (error) { - console.error('Error parsing config content, using empty config:', error); - config = {}; - } - - config.use_custom_excludes = state.useCustomExcludes; - config.use_custom_includes = state.useCustomIncludes; - config.use_gitignore = state.useGitignore; - config.enable_secret_scanning = state.enableSecretScanning; - config.exclude_suspicious_files = state.excludeSuspiciousFiles; - config.include_tree_view = state.includeTreeView; - config.show_token_count = state.showTokenCount; - config.export_format = state.exportFormat; - - config.include_extensions = state.fileExtensions - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - config.exclude_patterns = state.excludePatterns - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - let hasProviderValidationErrors = false; - if (aiSurfacesEnabled) { - const providerFields = { - providerId: state.providerId, - providerModel: state.providerModel, - providerApiKey: state.providerApiKey, - providerBaseUrl: state.providerBaseUrl, - }; - const validationErrors = getProviderValidationErrors(providerFields, t); - hasProviderValidationErrors = validationErrors.length > 0; - - if (hasProviderValidationErrors) { - setProviderValidationErrors(validationErrors); - setProviderTestResult({ - ok: false, - message: t('config.providerFixBeforeSaving'), - }); - } - - if (hasProviderValidationErrors) { - if (config.provider) { - delete config.provider; - } - } else if (hasProviderInput(providerFields) && state.providerId) { - config.provider = { - id: state.providerId, - model: state.providerModel.trim(), - api_key: trimToUndefined(state.providerApiKey), - base_url: trimToUndefined(state.providerBaseUrl), - }; - } else if (config.provider) { - delete config.provider; - } - } else { + const config = parseConfigContent(configContent); + applyBaseConfigState(config, state); + + const providerResult = applyProviderConfigState(config, state, aiSurfacesEnabled, t); + if (aiSurfacesEnabled && providerResult.hasValidationErrors) { + setProviderValidationErrors(providerResult.validationErrors); + setProviderTestResult({ + ok: false, + message: t('config.providerFixBeforeSaving'), + }); + } else if (!aiSurfacesEnabled) { setProviderValidationErrors([]); setProviderTestResult(null); } @@ -315,15 +353,16 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { onConfigChange(updatedConfig); - if (hasProviderValidationErrors) { + if (providerResult.hasValidationErrors) { setIsSaved(false); - } else { - setProviderValidationErrors([]); - setIsSaved(true); - setTimeout(() => { - setIsSaved(false); - }, 1500); + return; } + + setProviderValidationErrors([]); + setIsSaved(true); + setTimeout(() => { + setIsSaved(false); + }, 1500); } catch (error) { console.error('Error updating config:', error); } diff --git a/src/renderer/feature-flags.ts b/src/renderer/feature-flags.ts index 647bcd5..57b7b0a 100644 --- a/src/renderer/feature-flags.ts +++ b/src/renderer/feature-flags.ts @@ -3,11 +3,11 @@ const RENDERER_FEATURE_FLAGS = { } as const; const isDevMode = (): boolean => { - if (typeof window === 'undefined') { + if (globalThis.window === undefined) { return false; } - return window.devUtils?.isDev === true; + return globalThis.window.devUtils?.isDev === true; }; export const isAiSurfacesEnabled = (): boolean => { diff --git a/src/renderer/i18n/index.ts b/src/renderer/i18n/index.ts index d77fa61..641df7f 100644 --- a/src/renderer/i18n/index.ts +++ b/src/renderer/i18n/index.ts @@ -30,4 +30,6 @@ if (!i18n.isInitialized) { }); } -export default i18n; +const i18nInstance = i18n; + +export default i18nInstance; diff --git a/src/renderer/i18n/settings.ts b/src/renderer/i18n/settings.ts index b6a5c55..87ac7a2 100644 --- a/src/renderer/i18n/settings.ts +++ b/src/renderer/i18n/settings.ts @@ -9,6 +9,14 @@ export const isSupportedLocale = (value: string | null | undefined): value is Su return value !== null && value !== undefined && SUPPORTED_LOCALES.includes(value as SupportedLocale); }; +const getBrowserWindow = (): Window | undefined => { + if (globalThis.window === undefined) { + return undefined; + } + + return globalThis.window; +}; + const normalizeLocale = (value: string): SupportedLocale | null => { if (isSupportedLocale(value)) { return value; @@ -23,11 +31,12 @@ const normalizeLocale = (value: string): SupportedLocale | null => { }; export const getInitialLocale = (): SupportedLocale => { - if (typeof window === 'undefined') { + const browserWindow = getBrowserWindow(); + if (!browserWindow) { return DEFAULT_LOCALE; } - const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY); + const storedLocale = browserWindow.localStorage.getItem(LOCALE_STORAGE_KEY); if (storedLocale) { const normalizedStoredLocale = normalizeLocale(storedLocale); if (normalizedStoredLocale) { @@ -36,8 +45,8 @@ export const getInitialLocale = (): SupportedLocale => { } const languageCandidates = [ - ...(window.navigator.languages || []), - window.navigator.language, + ...(browserWindow.navigator.languages || []), + browserWindow.navigator.language, ].filter(Boolean); for (const candidate of languageCandidates) { @@ -51,9 +60,10 @@ export const getInitialLocale = (): SupportedLocale => { }; export const persistLocale = (locale: SupportedLocale): void => { - if (typeof window === 'undefined') { + const browserWindow = getBrowserWindow(); + if (!browserWindow) { return; } - window.localStorage.setItem(LOCALE_STORAGE_KEY, locale); + browserWindow.localStorage.setItem(LOCALE_STORAGE_KEY, locale); }; diff --git a/tests/catalog.md b/tests/catalog.md index d97b39a..e021f19 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -84,10 +84,10 @@ Stress benchmark outputs: ## Visual Regression Signal -| Command | Primary Target | Key Use Cases | -| -------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `npm run qa:screenshot` | `scripts/capture-ui-screenshot.js` + renderer UI | Cross-OS UI sanity, resized layout checks, deep file-tree selection visibility, secret-filter toggle behavior | -| `npm run docs:screenshots` | `scripts/generate-doc-screenshots.js` + renderer UI | Refresh tracked screenshots for Config/Select/Processed panels in `docs/APP_VIEWS.md` | +| Command | Primary Target | Key Use Cases | +| -------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run qa:screenshot` | `scripts/capture-ui-screenshot.js` + renderer UI | Cross-OS UI sanity, EN/ES/FR/DE locale screenshot captures, resized layout checks, deep file-tree selection visibility, secret-filter toggle behavior | +| `npm run docs:screenshots` | `scripts/generate-doc-screenshots.js` + renderer UI | Refresh tracked screenshots for Config/Select/Processed panels in `docs/APP_VIEWS.md` | ## Manual UI Doc Test From 16f981b7d8327e0c1ba3e4ff3114cc78853517af Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sat, 14 Feb 2026 19:24:04 +0000 Subject: [PATCH 2/2] fix(qa): derive locale screenshots from single source --- scripts/capture-ui-screenshot.js | 25 ++++++++----------------- src/renderer/feature-flags.ts | 10 +++++----- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index 32987f3..e9835d4 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -334,13 +334,15 @@ const MOCK_VISIBLE_FILE_COUNT_WITH_SECRET_FILTER = countMockFiles(MOCK_FILTERED_ const SCREENSHOT_NAME = sanitizeScreenshotName(process.env.UI_SCREENSHOT_NAME); const SCREENSHOT_BASE_NAME = path.parse(SCREENSHOT_NAME).name; const SCREENSHOT_PATH = resolveOutputPath(SCREENSHOT_NAME); +const LOCALE_SCREENSHOT_PATHS = Object.fromEntries( + SUPPORTED_LOCALES.map((locale) => [ + locale, + resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-${locale}.png`), + ]) +); const SCREENSHOTS = { configDefault: SCREENSHOT_PATH, - localeEn: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-en.png`), - localeEs: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-es.png`), - localeFr: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-fr.png`), - localeDe: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-locale-de.png`), sourceTab: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-source.png`), sourceSelected: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-source-selected.png`), sourceSelectedResized: resolveOutputPath(`${SCREENSHOT_BASE_NAME}-source-selected-resized.png`), @@ -368,13 +370,6 @@ const UI_SELECTORS = { processedContent: '#processed-content', }; -const LOCALE_SCREENSHOT_KEYS = { - en: 'localeEn', - es: 'localeEs', - fr: 'localeFr', - de: 'localeDe', -}; - async function setupMockElectronApi(page) { await page.addInitScript( ({ mockRootPath, mockConfig, mockDirectoryTree, mockFilteredDirectoryTree, fixedMtime }) => { @@ -532,8 +527,7 @@ async function captureLocaleScreenshots(page) { }); await runStep(`Capture locale screenshot (${locale})`, async () => { - const screenshotKey = LOCALE_SCREENSHOT_KEYS[locale]; - await page.screenshot({ path: SCREENSHOTS[screenshotKey], fullPage: true }); + await page.screenshot({ path: LOCALE_SCREENSHOT_PATHS[locale], fullPage: true }); }); } @@ -761,10 +755,7 @@ async function captureScreenshot() { await captureAppStateScreenshots(page); const screenshotPaths = [ SCREENSHOTS.configDefault, - SCREENSHOTS.localeEn, - SCREENSHOTS.localeEs, - SCREENSHOTS.localeFr, - SCREENSHOTS.localeDe, + ...SUPPORTED_LOCALES.map((locale) => LOCALE_SCREENSHOT_PATHS[locale]), SCREENSHOTS.sourceTab, SCREENSHOTS.sourceSelected, SCREENSHOTS.sourceSelectedResized, diff --git a/src/renderer/feature-flags.ts b/src/renderer/feature-flags.ts index 57b7b0a..bf1c8d1 100644 --- a/src/renderer/feature-flags.ts +++ b/src/renderer/feature-flags.ts @@ -2,12 +2,12 @@ const RENDERER_FEATURE_FLAGS = { aiSurfaces: true, } as const; -const isDevMode = (): boolean => { - if (globalThis.window === undefined) { - return false; - } +const getBrowserWindow = (): Window | undefined => { + return globalThis.window; +}; - return globalThis.window.devUtils?.isDev === true; +const isDevMode = (): boolean => { + return getBrowserWindow()?.devUtils?.isDev === true; }; export const isAiSurfacesEnabled = (): boolean => {