diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index c827476..e9835d4 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() { @@ -332,6 +334,12 @@ 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, @@ -343,6 +351,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"]', @@ -368,6 +377,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 +498,44 @@ 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 () => { + await page.screenshot({ path: LOCALE_SCREENSHOT_PATHS[locale], 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 +562,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 +753,16 @@ 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, + ...SUPPORTED_LOCALES.map((locale) => LOCALE_SCREENSHOT_PATHS[locale]), + 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..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 (typeof window === 'undefined') { - return false; - } +const getBrowserWindow = (): Window | undefined => { + return globalThis.window; +}; - return window.devUtils?.isDev === true; +const isDevMode = (): boolean => { + return getBrowserWindow()?.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