d3ro-voice/apps/desktop/tests/e2e/red_team_cycle5.spec.ts
Yun Chan f064c93197 feat(desktop): information architecture overhaul — home, records, meetings first
Wave 2 of the 2026-09-28 design overhaul ("records are the main
character, a quiet instrument"). Decisions: design.md.

- Shell: sidebar with primary Home / Records / Meetings (Ctrl+1..3) and a
  secondary "Tools" group; every item is a button with aria-current.
  The status bar is removed — its "100% LOCAL PRIVACY · ZERO CLOUD SYNC"
  claim contradicted device sync; the sidebar shows only queried state.
- Navigation context (routes, params, back, useRouteRequest) so Home can
  open a specific meeting or record.
- Home replaces the dashboard: dictation key, start actions, only real
  problems, one-line summary, recent records and meetings, file
  transcription, free-tier limits.
- Settings become a full-screen route with vertical tabs (tabs no longer
  clip) and a shared row grammar; long-standing bugs fixed: team and
  enterprise tiers crashed the license tab (currentTier undefined),
  first-run onboarding could not be left, Pro+ saw "upgrade to Pro+".
- Records: date-grouped single list with in-place expansion, search and
  tags together, mode/favorite filters, load more past 50, undoable
  delete. Meetings: list with search/rename/delete, summary-first detail
  (summary | transcript | documents | my notes), notes persisted locally
  instead of being lost. Tools pages cleaned up; stale-closure example
  chips and a 1550x mouse-distance bug fixed.
- Shared pieces: PageHeader, EmptyStateCard, SearchInput ("/" works),
  ConfirmDialog, notify with undo; display-labels maps domain values to
  typed i18n keys so missing keys fail to compile.
- Mono font stack falls back to Pretendard for Hangul (Korean labels no
  longer render spaced out).
- i18n: new ko/en keys via scripts/i18n-add-keys.mjs (one read-merge-
  write so parallel editors cannot clobber locale files).

Some renderer files also carried pre-existing in-flight changes (error
recovery deep links, meeting document hooks); they are included as-is.
2026-09-28 20:46:08 +09:00

265 lines
9 KiB
TypeScript

import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';
import path from 'path';
import fs from 'fs';
const SCREENSHOT_DIR = path.resolve('C:/Users/encep/.gemini/antigravity/brain/bbff18a3-721d-4c43-989f-1d6964e15be7/screenshots');
test.describe.serial('Extreme Red Team - Cycle 5: Rapid Route Thrashing, Onboarding Wizard & Input Fuzzing', () => {
let app: ElectronApplication;
let window: Page;
let testUserDataDir: string;
let uncaughtExceptions: string[] = [];
test.beforeAll(async () => {
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
testUserDataDir = path.join(
'C:/Users/encep/AppData/Local/Temp',
'playwright-redteam-cycle5-' + Date.now()
);
app = 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 app.windows()) {
try {
const url = w.url();
if (url && url.includes('index.html') && !url.includes('popups/')) {
window = w;
break;
}
} catch {
// window navigating
}
}
if (window) break;
await new Promise((r) => setTimeout(r, 400));
}
if (!window) {
window = app.windows()[0] || (await app.firstWindow());
}
window.on('pageerror', (err) => {
console.error('[PAGEERROR in Electron Window]:', err.message);
uncaughtExceptions.push(err.message);
});
await window.waitForLoadState('domcontentloaded');
// Bypass Onboarding modal initially
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 (app) {
await app.close();
}
});
test.beforeEach(() => {
uncaughtExceptions = [];
});
test('RT-16: Rapid Route Thrashing & Concurrency Stress Test', async () => {
// 7 navigation routes in AppLayout
const routes = [
'대시보드',
'기록',
'단어장',
'명령어',
'음성 대화',
'지식 베이스',
'회의록'
];
// Rapid thrashing loop - 10 full passes at high speed
for (let loop = 0; loop < 5; loop++) {
for (const routeName of routes) {
const navButton = window.getByText(routeName, { exact: true }).first();
if (await navButton.isVisible()) {
await navButton.click();
// Hyper rapid 40ms interval
await window.waitForTimeout(40);
}
}
}
// Settle back to dashboard
const dashboardNav = window.getByText('대시보드', { exact: true }).first();
await dashboardNav.click();
await window.waitForTimeout(500);
// Verify system survived rapid thrashing and dashboard is completely healthy
await expect(window.getByText(/총 발화|오늘 사용량|음성 인식|대시보드/i).first()).toBeVisible({ timeout: 5000 });
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt16_route_thrashing_settled.png'),
});
expect(uncaughtExceptions).toEqual([]);
});
test('RT-17: Onboarding Modal & First-Run Wizard Headful Execution', async () => {
// 1. Reset onboarding to false and reload to trigger OnboardingModal
await window.evaluate(async () => {
if (window.electronAPI?.config) {
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: false });
}
});
await window.reload();
await window.waitForLoadState('domcontentloaded');
await window.waitForTimeout(1000);
// 2. Verify OnboardingModal is visible
await expect(window.getByText(/D3RO Voice 시작하기/i)).toBeVisible({ timeout: 10000 });
await expect(window.getByText(/로컬 모드/i).first()).toBeVisible();
await expect(window.getByText(/온라인 모드/i).first()).toBeVisible();
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt17_01_onboarding_select_mode.png'),
});
// 3. Test Local Mode Selection
await window.getByRole('button', { name: /로컬 모드로 시작/i }).click();
await window.waitForTimeout(600);
// Verify transitions to local_ollama_setup phase
await expect(window.getByText(/Ollama에 연결/i).first()).toBeVisible({ timeout: 5000 });
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt17_02_onboarding_local_setup.png'),
});
// Test back button
const backBtn = window.getByRole('button', { name: '뒤로', exact: true });
await expect(backBtn).toBeVisible();
await backBtn.click();
await window.waitForTimeout(500);
// Verify back to select_mode
await expect(window.getByText(/로컬 모드/i).first()).toBeVisible();
// 4. Test Online Cloud Mode Selection
await window.getByRole('button', { name: /계정으로 로그인/i }).click();
await window.waitForTimeout(600);
// Verify transitions to online_auth phase
await expect(window.getByText(/D3RO 계정으로 로그인하면 온라인 모드로/i).first()).toBeVisible({ timeout: 5000 });
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt17_03_onboarding_online_auth.png'),
});
// Go back again
await window.getByRole('button', { name: '뒤로', exact: true }).click();
await window.waitForTimeout(500);
// 5. Restore onboardingCompleted
await window.evaluate(async () => {
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true });
});
await window.reload();
await window.waitForLoadState('domcontentloaded');
await window.waitForTimeout(1000);
expect(uncaughtExceptions).toEqual([]);
});
test('RT-18: Adversarial Input Fuzzing, Boundary Conditions & SQL/XSS Injection Defense', async () => {
// 1. Dictionary Fuzzing
const dictNav = window.locator('text=/^(Dictionary|사전|단어장)$/').first();
await dictNav.click();
await window.waitForTimeout(600);
const sqlXssPayload = '\' OR \'1\'=\'1\'; <script>alert("XSS")</script>';
const safeReplacement = 'FuzzReplacement';
// Click 추가 button
const addWordBtn = window.locator('button:has-text("추가"), button:has-text("Add")').first();
await addWordBtn.click();
await window.waitForTimeout(400);
// Fill inputs in dialog
const wordInput = window.locator('div[role="dialog"] input').first();
const pronInput = window.locator('div[role="dialog"] input').nth(1);
await wordInput.fill(sqlXssPayload);
await pronInput.fill(safeReplacement);
// Save
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 word was stored safely without executing XSS or corrupting SQLite
await expect(window.getByText(sqlXssPayload).first()).toBeVisible({ timeout: 5000 });
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt18_01_dictionary_fuzzing_saved.png'),
});
// Clean up: delete the fuzzed word
const deleteBtn = window.locator('button[aria-label*="삭제"], button:has(svg.lucide-trash-2)').first();
if (await deleteBtn.isVisible()) {
await deleteBtn.click();
await window.waitForTimeout(400);
// Confirm dialog if any
const confirmBtn = window.locator('div[role="dialog"] button:has-text("삭제"), div[role="dialog"] button:has-text("Delete")').first();
if (await confirmBtn.isVisible()) {
await confirmBtn.click();
await window.waitForTimeout(400);
}
}
// 2. Knowledge Base Query Fuzzing (Regex Break Characters)
const kbNav = window.locator('text=/^(Knowledge|지식 베이스)$/').first();
await kbNav.click();
await window.waitForTimeout(600);
const regexBreakQuery = '[([{\\\\^$|?*+';
const kbSearchInput = window.locator('input[placeholder*="질문"], input[placeholder*="검색"]').first();
if (await kbSearchInput.isVisible()) {
await kbSearchInput.fill(regexBreakQuery);
await window.keyboard.press('Enter');
await window.waitForTimeout(800);
}
await window.screenshot({
path: path.join(SCREENSHOT_DIR, 'rt18_02_knowledge_fuzzing_query.png'),
});
// Settle back to dashboard
const dashboardNav = window.locator('text=/^(Dashboard|대시보드)$/').first();
await dashboardNav.click();
await window.waitForTimeout(500);
expect(uncaughtExceptions).toEqual([]);
});
});