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.
327 lines
14 KiB
TypeScript
327 lines
14 KiB
TypeScript
// 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);
|
|
});
|
|
});
|