fix(desktop): surface configuration and provider failures instead of hiding them

Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
This commit is contained in:
Yun Chan 2026-09-16 23:23:58 +09:00
parent c8d802d78f
commit 6ba25f53b7
43 changed files with 1721 additions and 204 deletions

View file

@ -0,0 +1,327 @@
// apps/desktop/tests/e2e/red_team_cycle1.spec.ts
// Extreme Red Team: Headful Deep Interactive Testing for Cycle 1 (Core CRUD)
import { test, expect, _electron as electron, type Page, type ElectronApplication } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
const SCREENSHOT_DIR = 'C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots';
test.describe('Extreme Red Team - Cycle 1: Core CRUD & State Integrity', () => {
test.describe.configure({ mode: 'serial' });
let electronApp: ElectronApplication;
let window: Page;
const consoleErrors: string[] = [];
const uncaughtExceptions: string[] = [];
test.beforeAll(async () => {
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
const testUserDataDir = path.join(
'C:/Users/encep/AppData/Local/Temp',
'playwright-redteam-cycle1-' + Date.now()
);
electronApp = await electron.launch({
args: [
'out/main/index.js',
'--disable-gpu',
'--no-sandbox',
`--user-data-dir=${testUserDataDir}`,
],
env: {
...process.env,
NODE_ENV: 'test',
},
});
// Find main window (skip popups like recording-tip, caption-overlay, etc.)
for (let i = 0; i < 50; i++) {
for (const w of electronApp.windows()) {
try {
const url = w.url();
if (url && url.includes('index.html') && !url.includes('popups/')) {
window = w;
break;
}
} catch {
// window might be navigating
}
}
if (window) break;
await new Promise((r) => setTimeout(r, 400));
}
if (!window) {
window = electronApp.windows()[0] || (await electronApp.firstWindow());
}
await window.waitForLoadState('domcontentloaded');
// Attach Error Sentinels
window.on('pageerror', (err) => {
const msg = `[PAGE_ERROR] ${err.message}\n${err.stack || ''}`;
uncaughtExceptions.push(msg);
// eslint-disable-next-line no-console
console.error(msg);
});
window.on('console', (msg) => {
if (msg.type() === 'error') {
const text = msg.text();
// Filter out benign CSP dev warnings
if (!text.includes('Electron Security Warning') && !text.includes('Content Security Policy')) {
consoleErrors.push(text);
}
}
});
// Bypass Onboarding modal for testing CRUD
await window.evaluate(async () => {
if (window.electronAPI?.config) {
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true });
await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' });
await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' });
}
});
await window.waitForTimeout(500);
await window.reload();
await window.waitForLoadState('domcontentloaded');
await window.waitForTimeout(1000);
});
test.afterAll(async () => {
if (electronApp) {
await electronApp.close();
}
});
test('RT-01: Full Navigation Across All Primary Sidebar Routes', async () => {
// 1. Dashboard
await window.locator('text=/^(Dashboard|대시보드)$/').first().click();
await expect(window.locator('text=/^(Current Backend|현재 백엔드)$/').first()).toBeVisible({ timeout: 5000 });
await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt01_01_dashboard.png') });
// 2. History
await window.locator('text=/^(History|히스토리|기록)$/').first().click();
await expect(window.locator('text=/^(History|히스토리|변환 기록)$/').first()).toBeVisible({ timeout: 5000 });
// 3. Dictionary
await window.locator('text=/^(Dictionary|사전|단어장)$/').first().click();
await expect(window.locator('text=/^(Custom Dictionary|커스텀 사전|단어장)$/').first()).toBeVisible({ timeout: 5000 });
// 4. Commands
await window.locator('text=/^(Commands|명령어)$/').first().click();
await expect(window.locator('text=/^(LLM Commands|LLM 명령어)$/').first()).toBeVisible({ timeout: 5000 });
// 5. Voice Conversation
await window.locator('text=/^(Conversation|대화|음성 대화)$/').first().click();
await expect(window.locator('text=/^(Voice Conversation|음성 대화)$/').first()).toBeVisible({ timeout: 5000 });
// 6. Knowledge Base
await window.locator('text=/^(Knowledge|지식 베이스)$/').first().click();
await expect(window.locator('text=/^(Knowledge Base|지식 베이스)$/').first()).toBeVisible({ timeout: 5000 });
// 7. Meeting Mode
await window.locator('text=/^(Meeting|회의|회의 모드)$/').first().click();
await expect(window.locator('text=/^(Meeting Mode|회의 모드)$/').first()).toBeVisible({ timeout: 5000 });
expect(uncaughtExceptions).toHaveLength(0);
});
test('RT-02: Dictionary Deep CRUD, Boundary Validation & Search Interaction', async () => {
// Navigate to Dictionary Tab
await window.locator('text=/^(Dictionary|사전|단어장)$/').first().click();
await expect(window.locator('text=/^(Custom Dictionary|커스텀 사전|단어장)$/').first()).toBeVisible();
// Click Add Word button (PhysicalButton tone="accent")
const addBtn = window.locator('header button:has-text("추가"), header button:has-text("Add"), button:has-text("추가"), button:has-text("Add")').first();
await expect(addBtn).toBeVisible({ timeout: 5000 });
await addBtn.click();
await window.waitForTimeout(500);
// Verify Add Dialog opens
const dialogTitle = window.locator('text=/^(단어 추가|Add Word)$/').first();
await expect(dialogTitle).toBeVisible({ timeout: 5000 });
// Test Boundary: Empty input should disable save button
const saveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first();
await expect(saveBtn).toBeDisabled();
// Fill valid data
const wordInput = window.locator('div[role="dialog"] input').first();
const pronInput = window.locator('div[role="dialog"] input').nth(1);
await wordInput.fill('D3RO_Voice_RedTeam_Keyword');
await pronInput.fill('디쓰리오 보이스 레드팀 키워드');
await expect(saveBtn).toBeEnabled();
// Save
await saveBtn.click();
await window.waitForTimeout(600);
// Verify new word appears in the list
const createdItem = window.locator('text=D3RO_Voice_RedTeam_Keyword').first();
await expect(createdItem).toBeVisible({ timeout: 5000 });
await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt02_01_dict_created.png') });
// Test Search Functionality
const searchInput = window.locator('input[placeholder*="검색"], input[placeholder*="Search"]').first();
await searchInput.fill('RedTeam');
await window.waitForTimeout(400);
await expect(createdItem).toBeVisible();
await searchInput.fill('NonExistentKeywordXYZ999');
await window.waitForTimeout(400);
await expect(window.locator('text=D3RO_Voice_RedTeam_Keyword')).not.toBeVisible();
await expect(window.locator('text=/^(검색 결과 없음|검색 결과가 없습니다|일치하는 단어가 없습니다|No results)$/').first()).toBeVisible({ timeout: 5000 });
// Clear search
await searchInput.fill('');
await window.waitForTimeout(400);
await expect(createdItem).toBeVisible();
// Test Edit
const editBtn = window.locator('button[aria-label="편집"], button[aria-label="Edit"]').first();
await expect(editBtn).toBeVisible({ timeout: 5000 });
await editBtn.click();
await window.waitForTimeout(400);
// Verify Edit Dialog opens
await expect(window.locator('text=/^(단어 편집|단어 수정|Edit Word)$/').first()).toBeVisible({ timeout: 5000 });
const editWordInput = window.locator('div[role="dialog"] input').first();
await editWordInput.fill('D3RO_Voice_RedTeam_Keyword_MOD');
const editSaveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first();
await editSaveBtn.click();
await window.waitForTimeout(600);
// Verify edited word appears
const editedItem = window.locator('text=D3RO_Voice_RedTeam_Keyword_MOD').first();
await expect(editedItem).toBeVisible({ timeout: 5000 });
// Test Delete
const deleteBtn = window.locator('button[aria-label="삭제"], button[aria-label="Delete"]').first();
await expect(deleteBtn).toBeVisible({ timeout: 5000 });
await deleteBtn.click();
await window.waitForTimeout(700);
// Verify deleted item is gone
await expect(window.locator('text=D3RO_Voice_RedTeam_Keyword_MOD')).not.toBeVisible();
// Test Adversarial Unicode & Long Text Entry
await addBtn.click();
await window.waitForTimeout(500);
const complexInput = '🔥⚡ 레드팀 极限 测试 12345 🚀🤖 (Special!@#$%)';
await wordInput.fill(complexInput);
await pronInput.fill('특수문자 발음');
await saveBtn.click();
await window.waitForTimeout(600);
// Verify complex item rendered safely without unhandled error
const complexItem = window.locator(`text=${complexInput}`).first();
await expect(complexItem).toBeVisible({ timeout: 5000 });
// Cleanup complex item
const complexDeleteBtn = window.locator('button[aria-label="삭제"], button[aria-label="Delete"]').first();
await complexDeleteBtn.click();
await window.waitForTimeout(700);
await expect(complexItem).not.toBeVisible();
expect(uncaughtExceptions).toHaveLength(0);
});
test('RT-03: LLM Commands Deep CRUD, Activation Toggle & Interactive Form Verification', async () => {
// Navigate to Commands Tab
await window.locator('text=/^(Commands|명령어)$/').first().click();
await expect(window.locator('text=/^(LLM Commands|LLM 명령어)$/').first()).toBeVisible();
// Click Add Command button
const addCommandBtn = window.locator('header button:has-text("추가"), header button:has-text("Add"), button:has-text("추가"), button:has-text("Add")').first();
await expect(addCommandBtn).toBeVisible({ timeout: 5000 });
await addCommandBtn.click();
await window.waitForTimeout(500);
// Verify Dialog opens
const dialogTitle = window.locator('text=/^(명령어 추가|Add Command)$/').first();
await expect(dialogTitle).toBeVisible({ timeout: 5000 });
// Fill Command Form
const nameInput = window.locator('div[role="dialog"] input').first();
const descInput = window.locator('div[role="dialog"] input').nth(1);
const promptInput = window.locator('div[role="dialog"] textarea').first();
await nameInput.fill('RT_BulletPoints_Custom');
await descInput.fill('레드팀 불릿포인트 요약 테스트');
await promptInput.fill('다음 음성 텍스트를 글머리 기호(bullet points)로 간결하게 요약해주세요:\n\n{text}');
const saveBtn = window.locator('div[role="dialog"] button:has-text("저장"), div[role="dialog"] button:has-text("Save")').first();
await saveBtn.click();
await window.waitForTimeout(600);
// Verify card is added
const createdCmd = window.locator('text=RT_BulletPoints_Custom').first();
await expect(createdCmd).toBeVisible({ timeout: 5000 });
await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt03_01_command_created.png') });
// Test Edit Command
const editBtn = window.getByRole('button', { name: '편집' }).last();
await expect(editBtn).toBeVisible({ timeout: 5000 });
await editBtn.click();
await window.waitForTimeout(400);
await expect(window.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const editNameInput = window.getByRole('dialog').locator('input').first();
await editNameInput.fill('RT_BulletPoints_Custom_MOD');
await window.getByRole('dialog').getByRole('button', { name: '저장' }).click();
await window.waitForTimeout(600);
// Verify modified command
const editedCmd = window.locator('text=RT_BulletPoints_Custom_MOD').first();
await expect(editedCmd).toBeVisible({ timeout: 5000 });
// Delete Command
const deleteBtn = window.getByRole('button', { name: '삭제' }).first();
await expect(deleteBtn).toBeVisible({ timeout: 5000 });
await deleteBtn.click();
await window.waitForTimeout(700);
// Verify deleted
await expect(window.locator('text=RT_BulletPoints_Custom_MOD')).not.toBeVisible();
expect(uncaughtExceptions).toHaveLength(0);
});
test('RT-04: History Search, Filter and Card Interaction Integrity', async () => {
// Navigate to History Tab
await window.locator('text=/^(History|히스토리|기록)$/').first().click();
await expect(window.locator('text=/^(History|히스토리|변환 기록)$/').first()).toBeVisible();
// Verify Search Input exists and accepts query
const searchInput = window.locator('input[placeholder*="검색"], input[placeholder*="Search"]').first();
await expect(searchInput).toBeVisible();
await searchInput.fill('오늘');
await window.waitForTimeout(300);
await searchInput.fill('');
await window.waitForTimeout(300);
await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt04_01_history_view.png') });
expect(uncaughtExceptions).toHaveLength(0);
});
test('RT-05: Dashboard Health & Stat Widgets Non-Crashing Check', async () => {
// Navigate to Dashboard Tab
await window.locator('text=/^(Dashboard|대시보드)$/').first().click();
await expect(window.locator('text=/^(Current Backend|현재 백엔드)$/').first()).toBeVisible({ timeout: 5000 });
// Check stats and status widgets
await expect(window.locator('text=/^(전사 기록|최근 변환 기록|최근 전사|Recent Transcriptions)/').first()).toBeVisible({ timeout: 5000 });
await expect(window.locator('text=/^(시스템 정상|System OK|정상)/').first()).toBeVisible({ timeout: 5000 });
await window.screenshot({ path: path.join(SCREENSHOT_DIR, 'rt05_01_dashboard_verified.png') });
expect(uncaughtExceptions).toHaveLength(0);
});
});