Skip to content

Commit ad72ed5

Browse files
committed
feat: Initialize the new Electron desktop application with core components, E2E tests, and build configurations.
1 parent c8e964c commit ad72ed5

20 files changed

Lines changed: 1796 additions & 131 deletions

AGENTS.md

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,29 @@ This is a monorepo for React Native projects using UniWind (Tailwind v4 CSS-firs
2727

2828
## Testing
2929

30-
> **⚠️ MANDATORY**: The desktop app (`apps/desktop`) is an **Electron.js** application. All E2E and UI testing MUST go through MCP Playwright — **never open a browser**.
31-
32-
### ✅ DO
33-
34-
- Use **MCP Playwright** tools for all UI testing and interaction:
35-
- `mcp_playwright_browser_navigate` — navigate to pages
36-
- `mcp_playwright_browser_snapshot` — inspect DOM and find element refs (preferred over screenshots)
37-
- `mcp_playwright_browser_click`, `mcp_playwright_browser_type`, `mcp_playwright_browser_fill_form` — interact with elements
38-
- `mcp_playwright_browser_console_messages`, `mcp_playwright_browser_network_requests` — debug issues
39-
- Navigate to `http://localhost:5173/` (Electron renderer process) for UI testing
40-
- Run `pnpm run dev` from the monorepo root before testing
41-
- **Save all screenshots to `.playwright-tmp/`** — this directory is gitignored. Use the `filename` parameter with a path prefix:
42-
- Example: `filename: '.playwright-tmp/my_screenshot.png'`
43-
- Or use `mcp_playwright_browser_run_code` with `path: '.playwright-tmp/screenshot.png'`
44-
45-
### ❌ DON'T
46-
47-
- **Never use `browser_subagent`** — it opens a separate browser and cannot attach to the Electron app
48-
- **Never open a standalone browser window** for testing — always test through MCP Playwright against localhost:5173
49-
- **Never use screenshots as the primary inspection method** — use `mcp_playwright_browser_snapshot` instead for DOM/accessibility tree
50-
- **Never save screenshots to the project root** — always use `.playwright-tmp/` directory to avoid polluting the repo with test artifacts
30+
> **⚠️ MANDATORY**: The desktop app (`apps/desktop`) is an **Electron.js** application. E2E tests use **Playwright's Electron support** (`_electron.launch()`) — **never open a standalone browser**.
31+
32+
### E2E Tests (Playwright + Electron)
33+
34+
Tests live in `apps/desktop/e2e/` and use custom fixtures from `e2e/fixtures.ts` that launch the real Electron app.
35+
36+
```bash
37+
# Run all E2E tests (builds first, then launches Electron)
38+
pnpm --filter @next-dev/desktop test:e2e
39+
40+
# Interactive Playwright UI mode
41+
pnpm --filter @next-dev/desktop test:e2e:ui
42+
```
43+
44+
- **Config**: `apps/desktop/playwright.config.ts`
45+
- **Fixtures**: `apps/desktop/e2e/fixtures.ts` — provides `electronApp` and `window`
46+
- Tests get a real `Page` from the Electron `BrowserWindow` — no `baseURL` or browser projects needed
47+
- Build the app first (`electron-vite build`) before running tests
48+
49+
### Agent Ad-Hoc Testing (MCP Playwright)
50+
51+
For interactive AI-agent testing during development, use **MCP Playwright** tools against the dev server:
52+
53+
#### ✅ DO
54+
55+
- Use **MCP Playwright** for electronjs not browser

apps/desktop/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Electron build output
2+
out/
3+
4+
# Playwright
5+
test-results/
6+
playwright-report/
7+
.playwright-tmp/

apps/desktop/e2e/app.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test, expect } from './fixtures';
2+
3+
test.describe('DesignForge Desktop', () => {
4+
test('app window opens with correct title', async ({ window }) => {
5+
const title = await window.title();
6+
expect(title).toContain('DesignForge');
7+
});
8+
9+
test('renderer loads without errors', async ({ window }) => {
10+
// Collect any uncaught errors during load
11+
const errors: string[] = [];
12+
window.on('pageerror', (err) => errors.push(err.message));
13+
14+
// Give the renderer a moment to settle
15+
await window.waitForTimeout(2000);
16+
17+
expect(errors).toHaveLength(0);
18+
});
19+
20+
test('main process exposes app version via IPC', async ({ electronApp }) => {
21+
const version = await electronApp.evaluate(async ({ app }) => {
22+
return app.getVersion();
23+
});
24+
expect(version).toBeTruthy();
25+
});
26+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { test, expect } from './fixtures';
2+
3+
test('Code tab shows Monaco editor with generated code', async ({ window }) => {
4+
// Wait for the app to fully render
5+
await window.waitForSelector('.panel-tabs', { timeout: 15000 });
6+
7+
// Click the Code tab
8+
const codeTab = window.locator('button.panel-tab').filter({ hasText: 'Code' });
9+
await expect(codeTab).toBeVisible();
10+
await codeTab.click();
11+
12+
// Verify the IDE preview panel appears
13+
const idePreview = window.locator('.ide-preview');
14+
await expect(idePreview).toBeVisible();
15+
16+
// Verify tab bar with App.tsx default active
17+
const activeTab = window.locator('.ide-tab[data-active="true"]');
18+
await expect(activeTab).toBeVisible();
19+
await expect(activeTab).toContainText('App.tsx');
20+
21+
// Verify action buttons exist (copy, word wrap)
22+
const actionBar = window.locator('.ide-action-bar');
23+
await expect(actionBar).toBeVisible();
24+
25+
// Wait for Monaco editor to load (can be slow in Electron builds)
26+
const editor = window.locator('.ide-editor .monaco-editor');
27+
await expect(editor).toBeVisible({ timeout: 30000 });
28+
29+
// Verify status bar is visible with language info
30+
const statusBar = window.locator('.ide-status-bar');
31+
await expect(statusBar).toBeVisible();
32+
await expect(statusBar).toContainText('TypeScript');
33+
await expect(statusBar).toContainText('line');
34+
35+
// Take a screenshot of the code preview
36+
await window.screenshot({ path: 'test-results/code-preview.png', fullPage: false });
37+
});
38+
39+
test('Code tab file selector via tab bar works', async ({ window }) => {
40+
await window.waitForSelector('.panel-tabs', { timeout: 15000 });
41+
42+
// Switch to Code tab
43+
await window.locator('button.panel-tab').filter({ hasText: 'Code' }).click();
44+
await expect(window.locator('.ide-preview')).toBeVisible();
45+
46+
// Wait for Monaco to load
47+
await expect(window.locator('.ide-editor .monaco-editor')).toBeVisible({ timeout: 30000 });
48+
49+
// Click the design-spec.ts tab
50+
const specTab = window.locator('.ide-tab').filter({ hasText: 'design-spec.ts' });
51+
await specTab.click();
52+
53+
// Verify it's now active
54+
await expect(specTab).toHaveAttribute('data-active', 'true');
55+
56+
// Verify the status bar updated to TypeScript
57+
await expect(window.locator('.ide-status-bar')).toContainText('TypeScript');
58+
});

apps/desktop/e2e/fixtures.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Electron test fixtures for Playwright.
3+
*
4+
* Provides `electronApp` and `window` fixtures that launch the built
5+
* DesignForge Electron app before each test and tear it down after.
6+
*
7+
* Usage:
8+
* import { test, expect } from './fixtures';
9+
* test('window title', async ({ window }) => {
10+
* expect(await window.title()).toContain('DesignForge');
11+
* });
12+
*/
13+
14+
import { test as base, type Page } from '@playwright/test';
15+
import { _electron as electron, type ElectronApplication } from 'playwright';
16+
import { resolve, dirname } from 'node:path';
17+
import { fileURLToPath } from 'node:url';
18+
19+
const __dirname = dirname(fileURLToPath(import.meta.url));
20+
21+
// Path to the built main process entry point
22+
const MAIN_ENTRY = resolve(__dirname, '../out/main/index.js');
23+
24+
type ElectronFixtures = {
25+
electronApp: ElectronApplication;
26+
window: Page;
27+
};
28+
29+
export const test = base.extend<ElectronFixtures>({
30+
// biome-ignore lint: Playwright fixture signature requires destructured use
31+
electronApp: async ({}, use) => {
32+
const app = await electron.launch({
33+
args: [MAIN_ENTRY],
34+
env: {
35+
...process.env,
36+
NODE_ENV: 'test',
37+
},
38+
});
39+
await use(app);
40+
await app.close();
41+
},
42+
43+
window: async ({ electronApp }, use) => {
44+
// Wait for the first BrowserWindow to open
45+
const window = await electronApp.firstWindow();
46+
// Wait until the renderer has fully loaded
47+
await window.waitForLoadState('domcontentloaded');
48+
await use(window);
49+
},
50+
});
51+
52+
export { expect } from '@playwright/test';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, expect } from './fixtures';
2+
3+
test('left and right panels are resizable via drag handles', async ({ window }) => {
4+
await window.waitForSelector('.editor-layout', { timeout: 15000 });
5+
6+
// Verify resize handles exist
7+
const leftHandle = window.locator('.resize-handle--left');
8+
const rightHandle = window.locator('.resize-handle--right');
9+
await expect(leftHandle).toBeVisible();
10+
await expect(rightHandle).toBeVisible();
11+
12+
// Get initial left panel width
13+
const leftPanel = window.locator('.editor-left-panel');
14+
const initialLeftWidth = await leftPanel.evaluate((el) => el.getBoundingClientRect().width);
15+
16+
// Drag left handle to the right to widen the left panel
17+
const leftBox = await leftHandle.boundingBox();
18+
if (!leftBox) throw new Error('Left handle not found');
19+
await window.mouse.move(leftBox.x + leftBox.width / 2, leftBox.y + leftBox.height / 2);
20+
await window.mouse.down();
21+
await window.mouse.move(leftBox.x + 100, leftBox.y + leftBox.height / 2, { steps: 5 });
22+
await window.mouse.up();
23+
24+
// Verify left panel got wider
25+
const newLeftWidth = await leftPanel.evaluate((el) => el.getBoundingClientRect().width);
26+
expect(newLeftWidth).toBeGreaterThan(initialLeftWidth);
27+
28+
// Get initial right panel width
29+
const rightPanel = window.locator('.editor-right-panel');
30+
const initialRightWidth = await rightPanel.evaluate((el) => el.getBoundingClientRect().width);
31+
32+
// Drag right handle to the left to widen the right panel
33+
const rightBox = await rightHandle.boundingBox();
34+
if (!rightBox) throw new Error('Right handle not found');
35+
await window.mouse.move(rightBox.x + rightBox.width / 2, rightBox.y + rightBox.height / 2);
36+
await window.mouse.down();
37+
await window.mouse.move(rightBox.x - 100, rightBox.y + rightBox.height / 2, { steps: 5 });
38+
await window.mouse.up();
39+
40+
// Verify right panel got wider
41+
const newRightWidth = await rightPanel.evaluate((el) => el.getBoundingClientRect().width);
42+
expect(newRightWidth).toBeGreaterThan(initialRightWidth);
43+
44+
// Take screenshot of resized layout
45+
await window.screenshot({ path: 'test-results/resizable-panels.png', fullPage: false });
46+
});

apps/desktop/package.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@
1111
"build": "electron-vite build",
1212
"preview": "electron-vite preview",
1313
"pack": "electron-builder --dir",
14-
"dist": "electron-builder"
14+
"dist": "electron-builder",
15+
"test:e2e": "electron-vite build && npx playwright test",
16+
"test:e2e:ui": "electron-vite build && npx playwright test --ui"
1517
},
1618
"dependencies": {
1719
"@electron-toolkit/utils": "^4.0.0",
20+
"@monaco-editor/react": "^4.7.0",
1821
"@next-dev/catalog": "workspace:*",
1922
"@next-dev/editor-core": "workspace:*",
20-
"@next-dev/json-render": "workspace:*"
23+
"@next-dev/json-render": "workspace:*",
24+
"monaco-editor": "^0.55.1"
2125
},
2226
"devDependencies": {
27+
"@playwright/test": "^1.51.0",
2328
"@tailwindcss/vite": "^4.1.0",
2429
"@types/react": "~19.1.10",
2530
"@types/react-dom": "~19.1.5",
@@ -28,13 +33,14 @@
2833
"electron-builder": "^25.1.8",
2934
"electron-vite": "^5.0.0",
3035
"lucide-react": "^0.513.0",
36+
"playwright": "^1.51.0",
3137
"react": "^19.2.0",
3238
"react-dom": "^19.2.0",
3339
"tailwindcss": "^4.1.0",
3440
"typescript": "~5.9.2",
3541
"vite-tsconfig-paths": "^4.3.0",
36-
"zustand": "^5.0.5",
37-
"zod": "^4.3.6"
42+
"zod": "^4.3.6",
43+
"zustand": "^5.0.5"
3844
},
3945
"build": {
4046
"appId": "com.nextdev.designforge",

apps/desktop/playwright.config.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Playwright config for DesignForge Electron E2E tests.
3+
*
4+
* This project uses Playwright's Electron support (_electron.launch()).
5+
* Tests MUST import { test, expect } from './fixtures' which provides
6+
* the `electronApp` and `window` fixtures — never open a standalone browser.
7+
*
8+
* @see https://playwright.dev/docs/api/class-electron
9+
* @see e2e/fixtures.ts
10+
*/
11+
import { defineConfig } from '@playwright/test';
12+
13+
export default defineConfig({
14+
testDir: './e2e',
15+
timeout: 60_000,
16+
retries: process.env.CI ? 2 : 0,
17+
18+
/* Electron tests must run serially — only one app instance at a time */
19+
workers: 1,
20+
fullyParallel: false,
21+
22+
forbidOnly: !!process.env.CI,
23+
24+
reporter: [
25+
['list'],
26+
['html', { open: 'never', outputFolder: 'playwright-report' }],
27+
],
28+
29+
outputDir: 'test-results',
30+
31+
/*
32+
* No "projects" block — we intentionally skip browser projects.
33+
* Electron launches its own Chromium via _electron.launch() in fixtures.ts.
34+
* No baseURL is needed; the renderer loads from the built Electron app.
35+
*/
36+
});

apps/desktop/src/main/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* 4. Spawn MCP server as child process
99
*/
1010

11-
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Menu } from 'electron';
11+
import { app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, Menu, session } from 'electron';
1212
import { dirname, join } from 'node:path';
1313
import { mkdir, readFile, writeFile } from 'node:fs/promises';
1414
import { watch, type FSWatcher } from 'node:fs';
@@ -17,6 +17,32 @@ import { setupMCPIPC } from './mcp-client';
1717

1818
let mainWindow: BrowserWindow | null = null;
1919

20+
/**
21+
* Override Content-Security-Policy headers at the session level so that
22+
* renderer fetch() calls can reach local LLM APIs (Ollama, PocketPaw, LM Studio)
23+
* and remote endpoints (OpenAI, etc.).
24+
*
25+
* electron-vite injects a restrictive CSP header by default that blocks
26+
* connections to localhost origins other than the dev-server port.
27+
*/
28+
function setupCSP(): void {
29+
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
30+
callback({
31+
responseHeaders: {
32+
...details.responseHeaders,
33+
'Content-Security-Policy': [
34+
"default-src 'self';" +
35+
" script-src 'self' 'unsafe-inline';" +
36+
" style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
37+
" font-src 'self' https://fonts.gstatic.com;" +
38+
" img-src 'self' data:;" +
39+
" connect-src 'self' http://localhost:* http://127.0.0.1:* https://* ws://localhost:* ws://127.0.0.1:*",
40+
],
41+
},
42+
});
43+
});
44+
}
45+
2046
function createWindow(): void {
2147
mainWindow = new BrowserWindow({
2248
width: 1440,
@@ -225,6 +251,7 @@ app.whenReady().then(() => {
225251
];
226252
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
227253

254+
setupCSP();
228255
setupIPC();
229256
setupMCPIPC();
230257
createWindow();

apps/desktop/src/renderer/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<meta
77
http-equiv="Content-Security-Policy"
8-
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:"
8+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' http://localhost:* http://127.0.0.1:* https://*"
99
/>
1010
<title>DesignForge</title>
1111
<link rel="preconnect" href="https://fonts.googleapis.com" />

0 commit comments

Comments
 (0)