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.
457 lines
17 KiB
TypeScript
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([]);
|
|
});
|
|
});
|