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