d3ro-voice/apps/desktop/tests/e2e/red_team_cycle2.spec.ts
Yun Chan 6ba25f53b7 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.
2026-09-16 23:23:58 +09:00

457 lines
17 KiB
TypeScript

// apps/desktop/tests/e2e/red_team_cycle2.spec.ts
// Extreme Red Team: Headful Deep Interactive Testing for Cycle 2 (AI Pipelines & Workspaces)
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 2: AI Pipelines & Advanced Workspaces', () => {
test.describe.configure({ mode: 'serial' });
let electronApp: ElectronApplication;
let window: Page;
const consoleErrors: string[] = [];
const uncaughtExceptions: string[] = [];
let testUserDataDir: string;
test.beforeAll(async () => {
test.setTimeout(60000);
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
testUserDataDir = path.join(
'C:/Users/encep/AppData/Local/Temp',
'playwright-redteam-cycle2-' + 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
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());
}
// Attach sentinels
window.on('pageerror', (err) => {
console.error('[PAGEERROR]', err.message);
uncaughtExceptions.push(err.message);
});
window.on('console', (msg) => {
if (msg.type() === 'error') {
const text = msg.text();
// Ignore benign chrome font/csp warnings
if (!text.includes('Failed to load resource') && !text.includes('favicon.ico')) {
consoleErrors.push(text);
}
}
});
await window.waitForLoadState('domcontentloaded');
// Bypass Onboarding modal for testing
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-06: Meeting Mode List, Start Recording Trigger & Diagnostic Dialog', async () => {
// 1. Navigate to Meeting Mode
const meetingNav = window.locator('text=/^(Meeting|회의)$/').first();
await expect(meetingNav).toBeVisible({ timeout: 5000 });
await meetingNav.click();
await window.waitForTimeout(600);
// Verify page header
await expect(window.locator('text=/^(Meeting|회의 모드)$/').first()).toBeVisible({ timeout: 5000 });
// 2. Click "새 회의 시작" button
const startBtn = window.getByRole('button', { name: /새 회의 시작|새 회의/i }).first();
await expect(startBtn).toBeVisible();
await startBtn.click();
await window.waitForTimeout(1000);
// If diagnostic dialog opened (e.g. no mic or whisper unavailable in test container)
const dialog = window.getByRole('dialog');
const isDialogVisible = await dialog.isVisible({ timeout: 2000 }).catch(() => false);
if (isDialogVisible) {
// Verify diagnostic elements
await expect(window.getByText('Meeting Intelligence Diagnostics')).toBeVisible();
await expect(window.getByText(/발생 원인/)).toBeVisible();
await expect(window.getByText(/해결 조치/)).toBeVisible();
// Click "닫기" button
const closeBtn = window.getByRole('button', { name: '닫기' });
await expect(closeBtn).toBeVisible();
await closeBtn.click();
await window.waitForTimeout(500);
await expect(dialog).not.toBeVisible();
}
// Take screenshot
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt06_meeting_list_and_dialog.png'),
});
expect(uncaughtExceptions).toEqual([]);
});
test('RT-07: Meeting Mode Detail Workspace - Inline Title, ViewModes, MarkdownEditor, Action Items & Chat', async () => {
// Seed a meeting session directly via python to avoid Node ABI mismatch
const sessionId = 'test-session-redteam-' + Date.now();
const dbPath = path.join(testUserDataDir, 'users', '_local', 'd3ro.db');
const seedPyPath = path.join(testUserDataDir, 'seed.py');
const now = Date.now();
const pyScript = `import sqlite3, sys
db_path = sys.argv[1]
session_id = sys.argv[2]
now = int(sys.argv[3])
conn = sqlite3.connect(db_path)
c = conn.cursor()
c.execute("""
INSERT INTO meeting_sessions (id, title, status, started_at, ended_at, duration_ms, raw_transcript, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
session_id,
'Red Team AI 전략 회의',
'completed',
now - 1800000,
now,
1800000,
'[00:05] [참석자 A] D3RO Voice 시스템 성능 점검을 시작합니다.\\n[00:15] [참석자 B] 극단적 레드팀 테스트를 수행 중입니다.\\n[01:00] [참석자 A] 품질 검증을 완벽하게 통과했습니다.',
now - 1800000,
now
))
c.execute("""
INSERT INTO meeting_memos (id, session_id, content, timestamp_ms, created_at)
VALUES (?, ?, ?, ?, ?)
""", (
'memo-' + session_id,
session_id,
'#결정 음성 인식 모델 레이턴시 최적화 완료',
5000,
now - 1795000
))
c.execute("""
INSERT INTO meeting_documents (id, session_id, template_type, title, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
'doc-' + session_id,
session_id,
'minutes',
'전략 회의록',
'# 레드팀 전략 회의록\\n\\n## 1. 개요\\n- 시스템 안정성 심층 검증\\n\\n## 2. 액션 아이템\\n- [ ] E2E 헤드풀 테스트 통과하기\\n- [x] SQLite ABI 불일치 수정 완료\\n\\n## 3. 결론\\n전체 파이프라인 완벽 가동',
now,
now
))
conn.commit()
conn.close()
`;
fs.writeFileSync(seedPyPath, pyScript, 'utf-8');
const cp = require('child_process');
cp.execSync(`python "${seedPyPath}" "${dbPath}" "${sessionId}" "${now}"`);
fs.unlinkSync(seedPyPath);
// Refresh meeting list by navigating to dashboard and back to meeting
await window.locator('text=/^(Dashboard|대시보드)$/').first().click();
await window.waitForTimeout(400);
await window.locator('text=/^(Meeting|회의)$/').first().click();
await window.waitForTimeout(800);
// Click on the seeded session card
const sessionCard = window.getByText('Red Team AI 전략 회의');
await expect(sessionCard).toBeVisible({ timeout: 5000 });
await sessionCard.click();
await window.waitForTimeout(800);
// 1. Verify MeetingDetailTabs is rendered
await expect(window.getByRole('tab', { name: '전략 회의록' })).toBeVisible({ timeout: 5000 });
// 2. Test Inline Title Editing
const editTitleBtn = window.locator('button:has(svg.lucide-pencil)');
if (await editTitleBtn.isVisible()) {
await editTitleBtn.click();
await window.waitForTimeout(300);
const titleInput = window.locator('input[value*="Red Team AI 전략 회의"]');
await expect(titleInput).toBeVisible();
await titleInput.fill('Red Team AI 전략 회의 (검증됨)');
const saveTitleBtn = window.locator('button:has(svg.lucide-check)');
await saveTitleBtn.click();
await window.waitForTimeout(500);
await expect(window.getByText('Red Team AI 전략 회의 (검증됨)')).toBeVisible();
}
// 3. Test ViewMode switches (SegmentControl)
const transcriptOnlyBtn = window.getByText('Transcript ▤');
if (await transcriptOnlyBtn.isVisible()) {
await transcriptOnlyBtn.click();
await window.waitForTimeout(300);
await expect(window.getByText(/D3RO Voice 시스템 성능 점검/)).toBeVisible();
const docOnlyBtn = window.getByText('Minutes ▥');
await docOnlyBtn.click();
await window.waitForTimeout(300);
await expect(window.getByRole('heading', { name: '레드팀 전략 회의록' })).toBeVisible();
const splitBtn = window.getByText('Split ◫');
await splitBtn.click();
await window.waitForTimeout(300);
}
// 4. Test MarkdownEditor preview vs edit toggle
const editModeBtn = window.locator('button').filter({ hasText: '편집' }).first();
if (await editModeBtn.isVisible()) {
await editModeBtn.click();
await window.waitForTimeout(300);
const textarea = window.locator('textarea');
await expect(textarea).toBeVisible();
await textarea.type('\n\n## 4. 추가 사항\n- 레드팀 사이클 2 통과');
await window.waitForTimeout(600); // let autosave debounce fire
const previewModeBtn = window.locator('button').filter({ hasText: '미리보기' }).first();
await previewModeBtn.click();
await window.waitForTimeout(300);
await expect(window.getByText(/추가 사항/)).toBeVisible();
}
// 5. Test Action Items tab
const actionItemsTab = window.getByText(/Action Items/i);
if (await actionItemsTab.isVisible()) {
await actionItemsTab.click();
await window.waitForTimeout(400);
await expect(window.getByText(/E2E 헤드풀 테스트/)).toBeVisible();
// Toggle action item checkbox
const checkbox = window.locator('input[type="checkbox"]').first();
await checkbox.click();
await window.waitForTimeout(300);
}
// 6. Test Scratchpad Notepad tab
const notepadTab = window.getByText(/Granola Notepad/i);
if (await notepadTab.isVisible()) {
await notepadTab.click();
await window.waitForTimeout(400);
const notepadTextarea = window.locator('textarea').first();
if (await notepadTextarea.isVisible()) {
await notepadTextarea.fill('핵심 안건: 실시간 STT 엔진 최적화 및 텍스트 렌더링 검증');
await window.waitForTimeout(300);
}
}
// 7. Test Export Menu
const exportBtn = window.getByRole('button', { name: /내보내기/i });
if (await exportBtn.isVisible()) {
await exportBtn.click();
await window.waitForTimeout(300);
await expect(window.getByText(/Markdown/i)).toBeVisible();
// Press Escape to dismiss menu
await window.keyboard.press('Escape');
await window.waitForTimeout(300);
}
// 8. Test Meeting Chat Panel
const chatInput = window.locator('input[placeholder*="AI에게 질문"]');
if (await chatInput.isVisible()) {
await chatInput.fill('회의 요약해줘');
await window.keyboard.press('Enter');
await window.waitForTimeout(600);
// Verify user message appears
await expect(window.getByText('회의 요약해줘')).toBeVisible();
// Clear chat
const clearChatBtn = window.locator('button:has(svg.lucide-trash2)');
if (await clearChatBtn.isVisible()) {
await clearChatBtn.click();
await window.waitForTimeout(300);
await expect(window.getByText('회의 요약해줘')).not.toBeVisible();
}
}
// 9. Back to meeting list
const backBtn = window.locator('button:has(svg.lucide-arrow-left)');
await backBtn.click();
await window.waitForTimeout(500);
await expect(window.getByText('Red Team AI 전략 회의 (검증됨)')).toBeVisible();
// Take screenshot
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt07_meeting_detail_workspace.png'),
});
expect(uncaughtExceptions).toEqual([]);
});
test('RT-08: Knowledge Base - Semantic Synthesis Query, Chips, Add & Delete Document', async () => {
// 1. Navigate to Knowledge Base
const ragNav = window.locator('text=/^(Knowledge Base|지식 베이스)$/').first();
await expect(ragNav).toBeVisible({ timeout: 5000 });
await ragNav.click();
await window.waitForTimeout(600);
// Verify page header
await expect(window.locator('text=/^(Knowledge Base|지식 베이스)$/').first()).toBeVisible({ timeout: 5000 });
// 2. Test query input
const queryInput = window.locator('input[placeholder*="질문하세요"]');
await expect(queryInput).toBeVisible();
await queryInput.fill('D3RO Voice 아키텍처');
// Click Search button
const searchBtn = window.getByRole('button', { name: /Search|검색/i });
await expect(searchBtn).toBeVisible();
await searchBtn.click();
await window.waitForTimeout(600);
// Verify searching spinner or graceful error feedback
await window.waitForTimeout(1000);
// 3. Add a mock document via IPC
const tempFilePath = path.join(testUserDataDir, 'red_team_sample.md');
fs.writeFileSync(
tempFilePath,
'# D3RO Voice 시스템 문서\n\nD3RO Voice는 고성능 로컬 퍼스트 음성 비서 아키텍처를 지원하며 빠른 STT 및 LLM 파이프라인을 제공합니다.',
'utf-8'
);
const addDocResult = await window.evaluate(async (filePath) => {
return await window.electronAPI.rag.addDocument({ filePath });
}, tempFilePath);
expect(addDocResult.success).toBe(true);
// Reload documents by re-navigating
await ragNav.click();
await window.waitForTimeout(800);
// Verify document card appears
await expect(window.getByText('red_team_sample.md')).toBeVisible({ timeout: 5000 });
await expect(window.getByText('MD', { exact: true })).toBeVisible();
// 4. Test Reindex button
const reindexBtn = window.locator('button:has(svg.lucide-refresh-cw)').first();
if (await reindexBtn.isVisible()) {
await reindexBtn.click();
await window.waitForTimeout(500);
}
// 5. Test Delete document button
const deleteDocBtn = window.locator('button:has(svg.lucide-trash-2)').first();
await expect(deleteDocBtn).toBeVisible();
await deleteDocBtn.click();
await window.waitForTimeout(600);
// Verify document is removed
await expect(window.getByText('red_team_sample.md')).not.toBeVisible();
// Clean up temp file
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
// Take screenshot
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt08_knowledge_base.png'),
});
expect(uncaughtExceptions).toEqual([]);
});
test('RT-09: Voice Conversation - Canvas, Text Chat, Mic Toggle & Clear History', async () => {
// 1. Navigate to Voice Conversation
const voiceNav = window.locator('text=/^(Talk|대화)$/').first();
await expect(voiceNav).toBeVisible({ timeout: 5000 });
await voiceNav.click();
await window.waitForTimeout(600);
// Verify PageHeader
await expect(window.locator('text=/^(Voice Conversation|음성 대화)$/').first()).toBeVisible({ timeout: 5000 });
await expect(window.getByText(/대기|IDLE/i).first()).toBeVisible();
// Verify empty state
await expect(window.getByText(/D3RO에게 말을 걸어보세요|Talk to D3RO/i)).toBeVisible();
// 2. Test Text Fallback Chat Input
const chatInput = window.locator('input[placeholder*="메시지 입력"]');
await expect(chatInput).toBeVisible();
await chatInput.fill('안녕 D3RO, 현재 상태 점검해줘');
const sendBtn = window.locator('button:has(svg.lucide-send)');
await expect(sendBtn).toBeVisible();
await sendBtn.click();
await window.waitForTimeout(1000);
// Verify user message appears in bubble
await expect(window.getByText('안녕 D3RO, 현재 상태 점검해줘')).toBeVisible({ timeout: 5000 });
// 3. Test Clear History
const clearBtn = window.locator('button:has(svg.lucide-trash-2)');
if (await clearBtn.isVisible()) {
await clearBtn.click();
await window.waitForTimeout(500);
}
// If session is active or thinking, end session so state returns to idle
const stopSessionBtn = window.getByRole('button', { name: /종료|End/i });
if (await stopSessionBtn.isVisible()) {
await stopSessionBtn.click();
await window.waitForTimeout(600);
}
await expect(window.getByText(/D3RO에게 말을 걸어보세요|Talk to D3RO/i)).toBeVisible({ timeout: 5000 });
// Take screenshot
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt09_voice_conversation.png'),
});
expect(uncaughtExceptions).toEqual([]);
});
});