Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ vstable 是一款专为开发者设计的现代数据库管理工具,支持可
- **Electron Main (`frontend/src/main/`)**:
- `daemon.ts`: 管理 Go 后端引擎进程的生命周期(启动、日志记录和停止)。
- `index.ts`: 处理 IPC 路由(如 `db:connect`, `db:query`,并通过 HTTP 代理到 Go 引擎)以及窗口管理。
- `store.ts`: 处理应用程序配置以及加密凭据的持久化
- `store.ts`: 处理应用程序配置、加密凭据以及工作区状态(标签页、会话)的持久化
- **React Renderer (`frontend/src/renderer/`)**:
- `features/`: 包含核心功能模块:
- `connection`: 数据库连接表单和管理。
Expand Down
3 changes: 3 additions & 0 deletions frontend/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@
**Resilience and Errors**
- R-01 Constraint Violation: 尝试插入重复主键或非法格式(如 UUID 列填入普通字符串),验证 AlertModal 报错。
- R-02 Connection Failure: 输入错误的凭据,验证 UI 显示明确的错误提示。

**Workspace Persistence**
- P-01 Restore Session: 连接数据库并打开页签,执行创建表格 SQL,打开表格后重启应用,验证自动连接并恢复之前的页签状态。
114 changes: 114 additions & 0 deletions frontend/e2e/workspace-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { _electron as electron, expect, test } from '@playwright/test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

test.describe('Workspace Persistence Tests', () => {
let userDataDir: string;

test.beforeAll(async () => {
userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vstable-e2e-persist-'));
});

test.afterAll(async () => {
if (userDataDir && fs.existsSync(userDataDir)) {
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch (e) {}
}
});

test('P-01 Connect, open tabs, and restore after restart', async () => {
// 1. First launch
let electronApp = await electron.launch({
args: ['.', '--no-sandbox', '--disable-gpu', `--user-data-dir=${userDataDir}`],
});
let window = await electronApp.firstWindow();
await window.waitForLoadState('domcontentloaded');

// Wait for engine
await expect(async () => {
const response = await window.request.get('http://127.0.0.1:39082/api/ping');
expect(response.ok()).toBeTruthy();
}).toPass({ timeout: 15000 });

// Connect to PostgreSQL
const form = window.locator('form[data-testid="connection-form"]');
await expect(form).toBeVisible({ timeout: 10000 });

await window.locator('button:has-text("PostgreSQL")').click();
await window.locator('input[data-testid="input-host"]').fill('127.0.0.1');
await window.locator('input[data-testid="input-port"]').fill('5433');
await window.locator('input[data-testid="input-user"]').fill('root');
await window.locator('input[data-testid="input-password"]').fill('password');
await window.locator('input[data-testid="input-database"]').fill('vstable_test');

await window.locator('button[data-testid="btn-connect"]').click();
await expect(form).not.toBeVisible({ timeout: 10000 });

// Create a table first to ensure it exists
const mod = os.platform() === 'darwin' ? 'Meta' : 'Control';
await window.keyboard.press(`${mod}+t`);
const activeTab = window.locator('div[data-testid="active-tab-content"]');
const editor = activeTab.locator('.monaco-editor').last();
await editor.click();
await window.keyboard.press(`${mod}+a`);
await window.keyboard.press('Backspace');
await window.keyboard.insertText('CREATE TABLE IF NOT EXISTS persist_test (id int);');
await activeTab.locator('button[data-testid="btn-run-query"]').click();
await expect(activeTab.locator('text=Loading data...')).not.toBeVisible({ timeout: 10000 });

// Open the table tab
await window.locator('button[data-testid="btn-refresh-tables"]').click();
const tableItem = window.locator('div[data-testid="table-item-persist_test"]');
await expect(tableItem).toBeVisible({ timeout: 10000 });
await tableItem.click();

// Ensure the tab appears
await expect(window.locator('div[data-testid="tab-table-persist_test"]')).toBeVisible();

// Wait for the data to finish loading to ensure all state updates are done
const activeTabContent = window.locator('div[data-testid="active-tab-content"]');
await expect(activeTabContent.locator('text=Loading data...')).not.toBeVisible({
timeout: 10000,
});

// Give it a moment to save workspace.json (CI can be slow, 1s debounce + IO)
const workspacePath = path.join(userDataDir, 'workspace.json');
await expect(async () => {
expect(fs.existsSync(workspacePath)).toBeTruthy();
const content = fs.readFileSync(workspacePath, 'utf8');
const data = JSON.parse(content);
const hasTableTab = data.sessions?.[0]?.tabs?.some(
(t: any) => t.type === 'table' && t.name === 'persist_test'
);
expect(hasTableTab).toBeTruthy();
}).toPass({ timeout: 15000 });

// Close the app
await electronApp.close();

// 2. Second launch
electronApp = await electron.launch({
args: ['.', '--no-sandbox', '--disable-gpu', `--user-data-dir=${userDataDir}`],
});
window = await electronApp.firstWindow();
await window.waitForLoadState('domcontentloaded');

// Wait for "Loading workspace..." to disappear
await expect(window.locator('text=Loading workspace...')).not.toBeVisible({ timeout: 15000 });

// Wait for engine
await expect(async () => {
const response = await window.request.get('http://127.0.0.1:39082/api/ping');
expect(response.ok()).toBeTruthy();
}).toPass({ timeout: 15000 });

// the tab should be restored
await expect(window.locator('div[data-testid="tab-table-persist_test"]')).toBeVisible({
timeout: 10000,
});

await electronApp.close();
});
});
21 changes: 20 additions & 1 deletion frontend/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ handleIPC('store:get-all', () => {
// 返回时解密密码,方便前端填充(仅在 IPC 通道传输)
return connections.map((c) => ({
...c,
password: c.encryptedPassword ? store.decryptPassword(c.encryptedPassword) : '',
password: c.encryptedPassword ? store.decryptPassword(c.encryptedPassword) : c.password || '',
}));
});

Expand All @@ -146,6 +146,25 @@ handleIPC('store:delete', (_, id) => {
store.deleteConnection(id);
});

handleIPC('store:get-workspace', () => {
const workspace = store.getWorkspace();
// decrypt passwords just like we do for get-all
if (workspace?.sessions) {
workspace.sessions.forEach((s: any) => {
if (s.config?.encryptedPassword) {
s.config.password = store.decryptPassword(s.config.encryptedPassword);
}
// If encryptedPassword is not present, it means password was saved as plain text or is empty.
// It is already in s.config.password from JSON if plain text, so no action needed.
});
}
return workspace;
});

handleIPC('store:save-workspace', (_, data) => {
store.saveWorkspace(data);
});

handleIPC('window:toggle-maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win) {
Expand Down
152 changes: 152 additions & 0 deletions frontend/src/main/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));

vi.mock('fs', () => ({
default: {
existsSync: mocks.existsSync,
readFileSync: mocks.readFileSync,
writeFileSync: mocks.writeFileSync,
},
existsSync: mocks.existsSync,
readFileSync: mocks.readFileSync,
writeFileSync: mocks.writeFileSync,
}));

vi.mock('electron', () => ({
app: {
getPath: vi.fn().mockReturnValue('/mock/userData'),
},
safeStorage: {
isEncryptionAvailable: vi.fn().mockReturnValue(false),
encryptString: vi.fn(),
decryptString: vi.fn(),
},
}));

vi.mock('path', async () => {
const actual = await vi.importActual('path');
return {
...actual,
default: {
...(actual as any),
join: (...args: any[]) => args.join('/'),
},
join: (...args: any[]) => args.join('/'),
};
});

import { safeStorage } from 'electron';
// Now import store after mocks
import * as store from './store';

describe('Store', () => {
const workspacePath = '/mock/userData/workspace.json';

beforeEach(() => {
vi.clearAllMocks();
});

describe('getWorkspace', () => {
it('returns null if file does not exist', () => {
mocks.existsSync.mockReturnValue(false);
const result = store.getWorkspace();
expect(result).toBeNull();
expect(mocks.existsSync).toHaveBeenCalledWith(workspacePath);
});

it('returns parsed json if file exists', () => {
const mockData = { sessions: [] };
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue(JSON.stringify(mockData));

const result = store.getWorkspace();
expect(result).toEqual(mockData);
});

it('returns null if parsing fails', () => {
mocks.existsSync.mockReturnValue(true);
mocks.readFileSync.mockReturnValue('invalid json');

const result = store.getWorkspace();
expect(result).toBeNull();
});
});

describe('saveWorkspace', () => {
it('saves serialized json to file', () => {
const mockData = { sessions: [] };
store.saveWorkspace(mockData);
expect(mocks.writeFileSync).toHaveBeenCalledWith(
workspacePath,
JSON.stringify(mockData, null, 2)
);
});

it('completely strips password when encryption is not available', () => {
(safeStorage.isEncryptionAvailable as any).mockReturnValue(false);
const mockData = {
sessions: [
{
config: {
id: 'conn-1',
password: 'secret_password',
},
},
],
};

store.saveWorkspace(mockData);

const expectedData = {
sessions: [
{
config: {
id: 'conn-1',
},
},
],
};
expect(mocks.writeFileSync).toHaveBeenCalledWith(
workspacePath,
JSON.stringify(expectedData, null, 2)
);
});

it('encrypts password when encryption is available', () => {
(safeStorage.isEncryptionAvailable as any).mockReturnValue(true);
(safeStorage.encryptString as any).mockReturnValue(Buffer.from('encrypted_secret'));

const mockData = {
sessions: [
{
config: {
password: 'secret_password',
},
},
],
};

store.saveWorkspace(mockData);

const expectedData = {
sessions: [
{
config: {
encryptedPassword: Buffer.from('encrypted_secret').toString('base64'),
},
},
],
};
expect(mocks.writeFileSync).toHaveBeenCalledWith(
workspacePath,
JSON.stringify(expectedData, null, 2)
);
expect(safeStorage.encryptString).toHaveBeenCalledWith('secret_password');
});
});
});
46 changes: 45 additions & 1 deletion frontend/src/main/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { app, safeStorage } from 'electron';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import type { ConnectionConfig, PersistedWorkspace } from '../renderer/types/session';

export interface ConnectionEntry {
id: string;
Expand All @@ -11,9 +12,11 @@ export interface ConnectionEntry {
user: string;
database: string;
encryptedPassword?: string;
password?: string;
}

const STORE_PATH = join(app.getPath('userData'), 'connections.json');
const WORKSPACE_PATH = join(app.getPath('userData'), 'workspace.json');

export function getSavedConnections(): ConnectionEntry[] {
if (!existsSync(STORE_PATH)) return [];
Expand All @@ -30,7 +33,7 @@ export function getSavedConnections(): ConnectionEntry[] {
}
}

export function saveConnection(config: any): void {
export function saveConnection(config: ConnectionConfig): void {
const connections = getSavedConnections();

// 处理加密
Expand All @@ -48,6 +51,7 @@ export function saveConnection(config: any): void {
user: config.user,
database: config.database,
encryptedPassword: encryptedPassword || undefined,
password: !safeStorage.isEncryptionAvailable() && config.password ? config.password : undefined,
};

const index = connections.findIndex((c) => c.id === entry.id);
Expand Down Expand Up @@ -75,3 +79,43 @@ export function decryptPassword(encryptedBase64: string): string {
return '';
}
}

export function getWorkspace(): PersistedWorkspace | null {
if (!existsSync(WORKSPACE_PATH)) return null;
try {
const content = readFileSync(WORKSPACE_PATH, 'utf-8');
return JSON.parse(content);
} catch (e) {
console.error('Failed to read workspace.json', e);
return null;
}
}

export function saveWorkspace(data: PersistedWorkspace): void {
try {
// Before saving, we must ensure passwords in configs are encrypted, or simply stripped,
// since connection config is already saved in connections.json.
// It's safer to not persist passwords in workspace.json.
// However, since it's just restoring connection context, removing password is fine
// as connect IPC logic will look it up if we have connection id, or we just rely on the user to re-enter it,
// wait, actually we can just encrypt passwords if they are present.
// Given the architecture, the user might just be connecting via an ad-hoc connection,
// we'll strip raw passwords to be safe and let them use encrypted ones if any.
const safeData = JSON.parse(JSON.stringify(data));
for (const session of safeData.sessions || []) {
if (session.config) {
if (session.config.password) {
if (safeStorage.isEncryptionAvailable()) {
session.config.encryptedPassword = safeStorage
.encryptString(session.config.password)
.toString('base64');
}
delete session.config.password;
}
}
}
writeFileSync(WORKSPACE_PATH, JSON.stringify(safeData, null, 2));
} catch (e) {
console.error('Failed to save workspace.json', e);
}
}
Loading
Loading