mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-12 17:44:14 +08:00
- 基于 TypeScript AST 精确识别 readFileSync 派生变量及其断言,仅删除不改写保留代码 - 断言全为源码文本的 it() 块整块移除,混合块中只剔除单条源码断言 - 清理随之失效的变量声明、空 it()/describe() 与无用 import,6 个文件因无用例残留而删除 - 累计移除 1283 处代码区间、8270 行,守卫基线由 130 条收敛至 12 条 - 全量测试失败集合无新增,TabManager 悬浮信息用例的 CRLF 误报一并消除 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1591 lines
50 KiB
TypeScript
1591 lines
50 KiB
TypeScript
import React from 'react';
|
||
import { readFileSync } from 'node:fs';
|
||
import { Button, Checkbox, Select } from 'antd';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import TableExportWorkbench, {
|
||
buildTableExportHistoryEntry,
|
||
resolveBatchWorkbenchObjects,
|
||
resolveTableExportColumnNames,
|
||
} from './TableExportWorkbench';
|
||
import {
|
||
ClearTables,
|
||
DBGetColumns,
|
||
DBGetDatabases,
|
||
DBGetTables,
|
||
DropDatabase,
|
||
DropTable,
|
||
ExportDatabaseSQLWithOptions,
|
||
ExportDatabasesSQLWithOptions,
|
||
ExportQueryWithOptions,
|
||
ExportSchemaSQLWithOptions,
|
||
ExportTableWithOptions,
|
||
ExportTablesSQLWithOptions,
|
||
} from '../../wailsjs/go/app/App';
|
||
import { loadViews } from './sidebar/sidebarMetadataLoaders';
|
||
import { setCurrentLanguage } from '../i18n';
|
||
import { buildBatchTableExportWorkbenchTab } from '../utils/tableExportTab';
|
||
import type { ExportProgressState } from './useExportProgressRunner';
|
||
import type { ExportProgressLogEntry } from './useExportProgressRunner';
|
||
import Modal from './common/ResizableDraggableModal';
|
||
|
||
const mockUpsertTableExportHistory = vi.fn();
|
||
const mockRunExportWithProgress = vi.fn();
|
||
const mockAddTab = vi.fn();
|
||
const mockAddSqlLog = vi.fn();
|
||
const mockUseExportProgressRunner = vi.fn();
|
||
const createMockStoreState = () => ({
|
||
theme: 'light',
|
||
connections: [
|
||
{
|
||
id: 'conn-1',
|
||
name: '本地',
|
||
config: {
|
||
type: 'mysql',
|
||
host: 'localhost',
|
||
port: 3306,
|
||
user: 'root',
|
||
database: 'SYS',
|
||
},
|
||
},
|
||
],
|
||
tableExportHistories: {},
|
||
upsertTableExportHistory: mockUpsertTableExportHistory,
|
||
addTab: mockAddTab,
|
||
addSqlLog: mockAddSqlLog,
|
||
});
|
||
const createMockProgressRunnerState = (): ExportProgressState => ({
|
||
open: true,
|
||
jobId: 'job-1',
|
||
title: '导出 SYS.test',
|
||
targetName: 'SYS.test',
|
||
format: 'XLSX',
|
||
startedAt: 1_000,
|
||
finishedAt: 0,
|
||
status: 'running',
|
||
stage: '正在写入文件',
|
||
current: 259_000,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
|
||
message: '',
|
||
});
|
||
const createProgressRunnerState = (
|
||
overrides: Partial<ExportProgressState> = {},
|
||
): ExportProgressState => ({
|
||
...createMockProgressRunnerState(),
|
||
...overrides,
|
||
});
|
||
const createIdleProgressRunnerState = (): ExportProgressState => createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
});
|
||
|
||
let mockStoreState = createMockStoreState();
|
||
let mockProgressRunnerState: ExportProgressState = createMockProgressRunnerState();
|
||
let mockProgressLogs: ExportProgressLogEntry[] = [];
|
||
|
||
vi.mock('antd', async () => {
|
||
const { createElement } = await import('react');
|
||
const component = (tag: string) => ({ children, ...props }: any) => createElement(tag, props, children);
|
||
return {
|
||
Alert: component('mock-alert'),
|
||
Button: component('mock-button'),
|
||
Checkbox: component('mock-checkbox'),
|
||
Empty: component('mock-empty'),
|
||
InputNumber: component('mock-input-number'),
|
||
Progress: component('mock-progress'),
|
||
Select: component('mock-select'),
|
||
Tooltip: component('mock-tooltip'),
|
||
message: {
|
||
loading: vi.fn(() => vi.fn()),
|
||
success: vi.fn(),
|
||
error: vi.fn(),
|
||
},
|
||
Typography: {
|
||
Paragraph: component('mock-paragraph'),
|
||
Text: component('mock-text'),
|
||
Title: component('mock-title'),
|
||
},
|
||
};
|
||
});
|
||
|
||
vi.mock('./common/ResizableDraggableModal', () => ({
|
||
default: { confirm: vi.fn() },
|
||
}));
|
||
|
||
vi.mock('@ant-design/icons', async () => {
|
||
const { createElement } = await import('react');
|
||
const icon = () => createElement('mock-icon');
|
||
return {
|
||
ClockCircleOutlined: icon,
|
||
DeleteOutlined: icon,
|
||
ExportOutlined: icon,
|
||
ReloadOutlined: icon,
|
||
};
|
||
});
|
||
|
||
vi.mock('../store', () => ({
|
||
useStore: (selector: (state: any) => any) => selector(mockStoreState),
|
||
}));
|
||
|
||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||
ClearTables: vi.fn(),
|
||
DBGetColumns: vi.fn(),
|
||
DBGetDatabases: vi.fn(),
|
||
DBGetTables: vi.fn(),
|
||
DropDatabase: vi.fn(),
|
||
DropTable: vi.fn(),
|
||
ExportDatabaseSQLWithOptions: vi.fn(),
|
||
ExportDatabasesSQLWithOptions: vi.fn(),
|
||
ExportQueryWithOptions: vi.fn(),
|
||
ExportSchemaSQLWithOptions: vi.fn(),
|
||
ExportTableWithOptions: vi.fn(),
|
||
ExportTablesSQLWithOptions: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('./sidebar/sidebarMetadataLoaders', () => ({
|
||
loadViews: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('./useExportProgressRunner', () => ({
|
||
useExportProgressRunner: (options: unknown) => {
|
||
mockUseExportProgressRunner(options);
|
||
return {
|
||
state: mockProgressRunnerState,
|
||
logs: mockProgressLogs,
|
||
reset: vi.fn(),
|
||
runExportWithProgress: mockRunExportWithProgress,
|
||
isRunning: ['start', 'running', 'finalizing'].includes(mockProgressRunnerState.status),
|
||
};
|
||
},
|
||
}));
|
||
|
||
describe('TableExportWorkbench', () => {
|
||
beforeEach(() => {
|
||
setCurrentLanguage('zh-CN');
|
||
mockUpsertTableExportHistory.mockReset();
|
||
mockRunExportWithProgress.mockReset();
|
||
mockAddTab.mockReset();
|
||
mockAddSqlLog.mockReset();
|
||
mockUseExportProgressRunner.mockReset();
|
||
mockProgressLogs = [];
|
||
vi.mocked(DBGetColumns).mockReset();
|
||
vi.mocked(DBGetDatabases).mockReset();
|
||
vi.mocked(DBGetTables).mockReset();
|
||
vi.mocked(ClearTables).mockReset();
|
||
vi.mocked(DropDatabase).mockReset();
|
||
vi.mocked(DropTable).mockReset();
|
||
vi.mocked(Modal.confirm).mockReset();
|
||
vi.mocked(loadViews).mockReset();
|
||
vi.mocked(loadViews).mockResolvedValue({ views: [], supported: true });
|
||
vi.mocked(ExportDatabaseSQLWithOptions).mockReset();
|
||
vi.mocked(ExportDatabasesSQLWithOptions).mockReset();
|
||
vi.mocked(ExportQueryWithOptions).mockReset();
|
||
vi.mocked(ExportSchemaSQLWithOptions).mockReset();
|
||
vi.mocked(ExportTableWithOptions).mockReset();
|
||
vi.mocked(ExportTablesSQLWithOptions).mockReset();
|
||
mockStoreState = createMockStoreState();
|
||
mockProgressRunnerState = createMockProgressRunnerState();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('renders the redesigned workbench with a single main progress area and elapsed time', () => {
|
||
vi.spyOn(Date, 'now').mockReturnValue(61_000);
|
||
|
||
const markup = renderToStaticMarkup(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-conn-1-SYS-SYS.test',
|
||
title: '导出 SYS.test',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableName: 'SYS.test',
|
||
objectType: 'table',
|
||
tableExportScopeOptions: [{ value: 'all', label: '全表数据' }],
|
||
tableExportInitialScope: 'all',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(markup).toContain('data-export-workbench-layout="true"');
|
||
expect(markup).toContain('data-export-workbench-main-progress="true"');
|
||
expect(markup).toContain('data-export-progress-mode="indeterminate"');
|
||
expect(markup).toContain('导出耗时');
|
||
expect(markup).toContain('01:00');
|
||
expect(markup).toContain('当前任务');
|
||
expect(markup).toContain('最近任务');
|
||
expect(markup).toContain('正在写入文件');
|
||
expect(markup).toContain('暂不显示百分比');
|
||
expect(markup).toContain('/Users/yangguofeng/Desktop/SYS.test.xlsx');
|
||
});
|
||
|
||
it('renders persisted history when reopening the workbench without an active export job', () => {
|
||
mockProgressRunnerState = {
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
};
|
||
mockStoreState = {
|
||
...createMockStoreState(),
|
||
tableExportHistories: {
|
||
'conn-1::SYS::SYS.test': [
|
||
{
|
||
jobId: 'job-finished-1',
|
||
targetName: 'SYS.test',
|
||
startedAt: 1_000,
|
||
finishedAt: 61_000,
|
||
format: 'XLSX',
|
||
scope: 'all',
|
||
scopeLabel: '全表数据',
|
||
strategyLabel: '整表导出链路',
|
||
status: 'done',
|
||
stage: '导出完成',
|
||
current: 500_000,
|
||
total: 500_000,
|
||
totalRowsKnown: true,
|
||
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
|
||
message: '',
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
const markup = renderToStaticMarkup(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-conn-1-SYS-SYS.test',
|
||
title: '导出 SYS.test',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableName: 'SYS.test',
|
||
objectType: 'table',
|
||
tableExportScopeOptions: [{ value: 'all', label: '全表数据' }],
|
||
tableExportInitialScope: 'all',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(markup).toContain('1 条记录');
|
||
expect(markup).toContain('导出完成');
|
||
expect(markup).toContain('/Users/yangguofeng/Desktop/SYS.test.xlsx');
|
||
});
|
||
|
||
it('renders batch table workbench copy and object progress summary', () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
title: '结构 · SYS',
|
||
targetName: 'SYS · 8 个对象',
|
||
format: 'SQL',
|
||
current: 3,
|
||
total: 8,
|
||
totalRowsKnown: true,
|
||
filePath: '/Users/yangguofeng/Desktop/SYS_schema_8tables.sql',
|
||
stage: '正在导出 orders (4/8)',
|
||
});
|
||
|
||
const markup = renderToStaticMarkup(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-batch-tables-conn-1-SYS',
|
||
title: '批量导出对象',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
exportWorkbenchMode: 'batch-tables',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(markup).toContain('模式 · 批量对象');
|
||
expect(markup).toContain('导出内容');
|
||
expect(markup).toContain('批量对象导出会统一生成一个 SQL 文件');
|
||
expect(markup).toContain('已完成 3 / 8 个对象');
|
||
expect(markup).toContain('/Users/yangguofeng/Desktop/SYS_schema_8tables.sql');
|
||
});
|
||
|
||
it('renders batch database history with directory-oriented labels', () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
});
|
||
mockStoreState = {
|
||
...createMockStoreState(),
|
||
tableExportHistories: {
|
||
'conn-1::__batch_databases__': [
|
||
{
|
||
jobId: 'job-batch-db-1',
|
||
targetName: '3 个数据库',
|
||
startedAt: 1_000,
|
||
finishedAt: 31_000,
|
||
format: 'SQL',
|
||
scope: 'selectedDatabases',
|
||
scopeLabel: '已选数据库(3)',
|
||
strategyLabel: '批量库 SQL 导出 · 导出库结构',
|
||
status: 'done',
|
||
stage: '导出完成',
|
||
current: 3,
|
||
total: 3,
|
||
totalRowsKnown: true,
|
||
filePath: '/Users/yangguofeng/Desktop/export-batch-dbs',
|
||
message: '',
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
const markup = renderToStaticMarkup(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-batch-databases-conn-1',
|
||
title: '批量导出库',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
exportWorkbenchMode: 'batch-databases',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(markup).toContain('模式 · 批量库');
|
||
expect(markup).toContain('将在开始导出时先选择输出目录');
|
||
expect(markup).toContain('已完成 3 / 3 个库');
|
||
expect(markup).toContain('/Users/yangguofeng/Desktop/export-batch-dbs');
|
||
expect(markup).toContain('目录');
|
||
});
|
||
|
||
it('normalizes table column metadata without changing database order', () => {
|
||
expect(resolveTableExportColumnNames([
|
||
{ Name: 'id' },
|
||
{ name: 'display_name' },
|
||
{ COLUMN_NAME: 'created_at' },
|
||
{ name: 'display_name' },
|
||
{ name: '' },
|
||
])).toEqual(['id', 'display_name', 'created_at']);
|
||
});
|
||
|
||
it('keeps view metadata out of destructive table targets', () => {
|
||
expect(resolveBatchWorkbenchObjects(
|
||
[{ Name: 'users' }, { Name: 'reporting.active_users' }],
|
||
[{ schemaName: 'reporting', viewName: 'active_users' }],
|
||
)).toEqual([
|
||
{ name: 'reporting.active_users', objectType: 'view' },
|
||
{ name: 'users', objectType: 'table' },
|
||
]);
|
||
});
|
||
|
||
it('keeps case-sensitive table identifiers as distinct batch targets', () => {
|
||
expect(resolveBatchWorkbenchObjects(
|
||
[{ Name: 'Foo' }, { Name: 'foo' }],
|
||
[],
|
||
)).toEqual([
|
||
{ name: 'Foo', objectType: 'table' },
|
||
{ name: 'foo', objectType: 'table' },
|
||
]);
|
||
});
|
||
|
||
it('syncs manually selected batch connection and database into the existing tab', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
mockStoreState = {
|
||
...createMockStoreState(),
|
||
connections: [
|
||
...createMockStoreState().connections,
|
||
{
|
||
id: 'conn-2',
|
||
name: '分析库',
|
||
config: {
|
||
type: 'postgres',
|
||
host: 'analytics.local',
|
||
port: 5432,
|
||
user: 'postgres',
|
||
database: 'analytics',
|
||
},
|
||
},
|
||
],
|
||
};
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Database: 'SYS' }, { Database: 'analytics' }],
|
||
} as any);
|
||
vi.mocked(DBGetTables).mockResolvedValue({ success: true, data: [] } as any);
|
||
|
||
const tab = {
|
||
id: 'table-export-batch-tables-conn-1-SYS',
|
||
title: '批量处理表',
|
||
type: 'table-export' as const,
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
exportWorkbenchMode: 'batch-tables' as const,
|
||
};
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(<TableExportWorkbench tab={tab} />);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const connectionSelect = renderer.root.findAllByType(Select).find((node) => (
|
||
Array.isArray(node.props.options)
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'conn-2')
|
||
));
|
||
expect(connectionSelect).toBeDefined();
|
||
await act(async () => {
|
||
connectionSelect?.props.onChange('conn-2');
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
expect(mockAddTab).toHaveBeenLastCalledWith(expect.objectContaining({
|
||
id: tab.id,
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'batch-tables',
|
||
connectionId: 'conn-2',
|
||
dbName: undefined,
|
||
}));
|
||
|
||
const databaseSelect = renderer.root.findAllByType(Select).find((node) => (
|
||
Array.isArray(node.props.options)
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'analytics')
|
||
&& !node.props.mode
|
||
));
|
||
expect(databaseSelect).toBeDefined();
|
||
await act(async () => {
|
||
databaseSelect?.props.onChange('analytics');
|
||
await Promise.resolve();
|
||
});
|
||
expect(mockAddTab).toHaveBeenLastCalledWith(expect.objectContaining({
|
||
id: tab.id,
|
||
connectionId: 'conn-2',
|
||
dbName: 'analytics',
|
||
}));
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('clears selected tables after confirmation and records the executed operation', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({ success: true, data: [{ Database: 'SYS' }] } as any);
|
||
vi.mocked(DBGetTables).mockResolvedValue({ success: true, data: [{ Name: 'users' }] } as any);
|
||
vi.mocked(ClearTables).mockResolvedValue({
|
||
success: true,
|
||
message: 'ok',
|
||
data: { count: 1, executedSQLs: ['TRUNCATE TABLE users'] },
|
||
} as any);
|
||
vi.mocked(Modal.confirm).mockImplementation((options: any) => {
|
||
options.onOk?.();
|
||
return { destroy: vi.fn(), update: vi.fn() } as any;
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-batch-tables-conn-1-SYS',
|
||
title: '批量处理表',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
exportWorkbenchMode: 'batch-tables',
|
||
tableExportInitialObjectNames: ['users'],
|
||
}}
|
||
/>,
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const clearButton = renderer.root.findByProps({ 'data-batch-clear-tables': 'true' });
|
||
expect(clearButton.props.disabled).toBe(false);
|
||
await act(async () => {
|
||
clearButton.props.onClick();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(ClearTables).toHaveBeenCalledWith(expect.objectContaining({ type: 'mysql' }), 'SYS', ['users']);
|
||
expect(mockAddSqlLog).toHaveBeenCalledWith(expect.objectContaining({
|
||
status: 'success',
|
||
dbName: 'SYS',
|
||
affectedRows: 1,
|
||
sql: expect.stringContaining('TRUNCATE TABLE users'),
|
||
}));
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('stops table deletion after a partial failure and retains unprocessed targets', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({ success: true, data: [{ Database: 'SYS' }] } as any);
|
||
vi.mocked(DBGetTables).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Name: 'alpha' }, { Name: 'beta' }, { Name: 'gamma' }],
|
||
} as any);
|
||
vi.mocked(DropTable)
|
||
.mockResolvedValueOnce({ success: true, message: 'ok' } as any)
|
||
.mockResolvedValueOnce({ success: false, message: 'blocked' } as any);
|
||
vi.mocked(Modal.confirm).mockImplementation((options: any) => {
|
||
options.onOk?.();
|
||
return { destroy: vi.fn(), update: vi.fn() } as any;
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-batch-tables-conn-1-SYS',
|
||
title: '批量处理表',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
exportWorkbenchMode: 'batch-tables',
|
||
tableExportInitialObjectNames: ['alpha', 'beta', 'gamma'],
|
||
}}
|
||
/>,
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const deleteButton = renderer.root.findByProps({ 'data-batch-delete-tables': 'true' });
|
||
expect(deleteButton.props.disabled).toBe(false);
|
||
await act(async () => {
|
||
deleteButton.props.onClick();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(DropTable).toHaveBeenCalledTimes(2);
|
||
expect(DropTable).toHaveBeenNthCalledWith(1, expect.anything(), 'SYS', 'alpha');
|
||
expect(DropTable).toHaveBeenNthCalledWith(2, expect.anything(), 'SYS', 'beta');
|
||
expect(DropTable).not.toHaveBeenCalledWith(expect.anything(), 'SYS', 'gamma');
|
||
expect(mockAddSqlLog).toHaveBeenCalledWith(expect.objectContaining({
|
||
status: 'error',
|
||
affectedRows: 1,
|
||
message: 'blocked',
|
||
}));
|
||
const objectSelect = renderer.root.findAllByType(Select).find((node) => node.props.mode === 'multiple');
|
||
expect(objectSelect?.props.value).toEqual(['beta', 'gamma']);
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('never enables clear or delete table actions when only views are selected', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({ success: true, data: [{ Database: 'SYS' }] } as any);
|
||
vi.mocked(DBGetTables).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Name: 'reporting.active_users' }],
|
||
} as any);
|
||
vi.mocked(loadViews).mockResolvedValue({
|
||
views: [{ schemaName: 'reporting', viewName: 'active_users' }],
|
||
supported: true,
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-batch-tables-conn-1-SYS',
|
||
title: '批量处理表',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
exportWorkbenchMode: 'batch-tables',
|
||
tableExportInitialObjectNames: ['reporting.active_users'],
|
||
}}
|
||
/>,
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const clearButton = renderer.root.findByProps({ 'data-batch-clear-tables': 'true' });
|
||
const deleteButton = renderer.root.findByProps({ 'data-batch-delete-tables': 'true' });
|
||
expect(clearButton.props.disabled).toBe(true);
|
||
expect(deleteButton.props.disabled).toBe(true);
|
||
await act(async () => {
|
||
clearButton.props.onClick();
|
||
deleteButton.props.onClick();
|
||
await Promise.resolve();
|
||
});
|
||
expect(Modal.confirm).not.toHaveBeenCalled();
|
||
expect(ClearTables).not.toHaveBeenCalled();
|
||
expect(DropTable).not.toHaveBeenCalled();
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('stops database deletion after a partial failure and retains unprocessed databases', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Database: 'alpha' }, { Database: 'beta' }, { Database: 'gamma' }],
|
||
} as any);
|
||
vi.mocked(DropDatabase)
|
||
.mockResolvedValueOnce({ success: true, message: 'ok' } as any)
|
||
.mockResolvedValueOnce({ success: false, message: 'in use' } as any);
|
||
vi.mocked(Modal.confirm).mockImplementation((options: any) => {
|
||
options.onOk?.();
|
||
return { destroy: vi.fn(), update: vi.fn() } as any;
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-batch-databases-conn-1',
|
||
title: '批量处理数据库',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
exportWorkbenchMode: 'batch-databases',
|
||
tableExportInitialDatabaseNames: ['alpha', 'beta', 'gamma'],
|
||
}}
|
||
/>,
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const deleteButton = renderer.root.findByProps({ 'data-batch-delete-databases': 'true' });
|
||
expect(deleteButton.props.disabled).toBe(false);
|
||
await act(async () => {
|
||
deleteButton.props.onClick();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(DropDatabase).toHaveBeenCalledTimes(2);
|
||
expect(DropDatabase).toHaveBeenNthCalledWith(1, expect.anything(), 'alpha');
|
||
expect(DropDatabase).toHaveBeenNthCalledWith(2, expect.anything(), 'beta');
|
||
expect(DropDatabase).not.toHaveBeenCalledWith(expect.anything(), 'gamma');
|
||
expect(mockAddSqlLog).toHaveBeenCalledWith(expect.objectContaining({
|
||
status: 'error',
|
||
affectedRows: 1,
|
||
message: 'in use',
|
||
}));
|
||
const databaseSelect = renderer.root.findAllByType(Select).find((node) => node.props.mode === 'multiple');
|
||
expect(databaseSelect?.props.value).toEqual(['beta', 'gamma']);
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('passes the MariaDB table as the INSERT target for current-page SQL export', async () => {
|
||
mockStoreState = {
|
||
...createMockStoreState(),
|
||
connections: [
|
||
{
|
||
...createMockStoreState().connections[0],
|
||
config: {
|
||
...createMockStoreState().connections[0].config,
|
||
type: 'mariadb',
|
||
database: 'app',
|
||
},
|
||
},
|
||
],
|
||
};
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
});
|
||
vi.mocked(DBGetColumns).mockResolvedValue({
|
||
success: true,
|
||
data: [{ name: 'id' }, { name: 'display_name' }],
|
||
} as any);
|
||
vi.mocked(ExportQueryWithOptions).mockResolvedValue({ success: true } as any);
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-conn-1-app-user',
|
||
title: '导出 user',
|
||
type: 'table-export',
|
||
connectionId: 'conn-1',
|
||
dbName: 'app',
|
||
tableName: 'user',
|
||
objectType: 'table',
|
||
tableExportScopeOptions: [{ value: 'page', label: '当前页(3 条)' }],
|
||
tableExportInitialScope: 'page',
|
||
tableExportQueryByScope: {
|
||
page: 'SELECT id, display_name FROM `user` LIMIT 3',
|
||
},
|
||
tableExportRowCountByScope: { page: 3 },
|
||
}}
|
||
/>,
|
||
);
|
||
});
|
||
|
||
const formatSelect = renderer.root.findAllByType(Select).find((node) => (
|
||
Array.isArray(node.props.options)
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'sql')
|
||
&& node.props.mode !== 'multiple'
|
||
));
|
||
expect(formatSelect).toBeDefined();
|
||
await act(async () => {
|
||
formatSelect?.props.onChange('sql');
|
||
});
|
||
|
||
const startButton = renderer.root.findAllByType(Button).find((node) => (
|
||
node.props.type === 'primary' && node.props.size === 'large'
|
||
));
|
||
expect(startButton?.props.disabled).toBe(false);
|
||
await act(async () => {
|
||
startButton?.props.onClick();
|
||
});
|
||
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
|
||
const run = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
|
||
await run('job-mariadb-current-page');
|
||
|
||
expect(ExportQueryWithOptions).toHaveBeenCalledWith(
|
||
expect.objectContaining({ type: 'mariadb' }),
|
||
'app',
|
||
'SELECT id, display_name FROM `user` LIMIT 3',
|
||
'user',
|
||
expect.objectContaining({
|
||
format: 'sql',
|
||
columns: ['id', 'display_name'],
|
||
insertSQLTargetTable: 'user',
|
||
jobId: 'job-mariadb-current-page',
|
||
totalRowsHint: 3,
|
||
totalRowsKnown: true,
|
||
}),
|
||
);
|
||
expect(ExportTableWithOptions).not.toHaveBeenCalled();
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('auto-starts a direct database backup inside the workbench', async () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
});
|
||
vi.mocked(ExportDatabaseSQLWithOptions).mockResolvedValue({ success: true } as any);
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-database-conn-1-SYS',
|
||
title: '备份 SYS',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'database',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableExportContentMode: 'backup',
|
||
tableExportIncludeDropIfExists: true,
|
||
tableExportRequestKey: 'database-backup-1',
|
||
}}
|
||
/>,
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
|
||
const run = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
|
||
await run('database-job-1');
|
||
|
||
expect(ExportDatabaseSQLWithOptions).toHaveBeenCalledWith(
|
||
expect.objectContaining({ type: 'mysql' }),
|
||
'SYS',
|
||
true,
|
||
expect.objectContaining({
|
||
format: 'sql',
|
||
jobId: 'database-job-1',
|
||
includeDropIfExists: true,
|
||
includeDatabaseContext: true,
|
||
}),
|
||
);
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('defaults database context by export mode and preserves a manual override', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(ExportDatabaseSQLWithOptions).mockResolvedValue({ success: true } as any);
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-database-conn-1-SYS',
|
||
title: '导出 SYS',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'database',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableExportContentMode: 'schema',
|
||
}}
|
||
/>,
|
||
);
|
||
});
|
||
|
||
const findDatabaseModeSelect = () => renderer.root.findAllByType(Select).find((node) => (
|
||
Array.isArray(node.props.options)
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'schema')
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'backup')
|
||
));
|
||
const findDatabaseContextCheckbox = () => renderer.root.findByProps({
|
||
'data-export-include-database-context': 'true',
|
||
});
|
||
|
||
expect(findDatabaseContextCheckbox().props.checked).toBe(false);
|
||
|
||
const startButton = renderer.root.findAllByType(Button).find((node) => (
|
||
node.props.type === 'primary' && node.props.size === 'large'
|
||
));
|
||
await act(async () => {
|
||
startButton?.props.onClick();
|
||
});
|
||
const run = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
|
||
await run('database-schema-job-1');
|
||
|
||
expect(ExportDatabaseSQLWithOptions).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ type: 'mysql' }),
|
||
'SYS',
|
||
false,
|
||
expect.objectContaining({
|
||
includeDropIfExists: false,
|
||
includeDatabaseContext: false,
|
||
}),
|
||
);
|
||
|
||
await act(async () => {
|
||
findDatabaseModeSelect()?.props.onChange('backup');
|
||
});
|
||
expect(findDatabaseContextCheckbox().props.checked).toBe(true);
|
||
|
||
await act(async () => {
|
||
findDatabaseContextCheckbox().props.onChange({ target: { checked: false } });
|
||
});
|
||
expect(findDatabaseContextCheckbox().props.checked).toBe(false);
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('auto-starts a direct schema export inside the workbench', async () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
});
|
||
vi.mocked(ExportSchemaSQLWithOptions).mockResolvedValue({ success: true } as any);
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-schema-conn-1-SYS-sales',
|
||
title: '导出 SYS.sales',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'schema',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
schemaName: 'sales',
|
||
tableExportContentMode: 'schema',
|
||
tableExportRequestKey: 'schema-export-1',
|
||
}}
|
||
/>,
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
|
||
const run = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
|
||
await run('schema-job-1');
|
||
|
||
expect(ExportSchemaSQLWithOptions).toHaveBeenCalledWith(
|
||
expect.objectContaining({ type: 'mysql', database: 'SYS' }),
|
||
'SYS',
|
||
'sales',
|
||
false,
|
||
expect.objectContaining({
|
||
format: 'sql',
|
||
jobId: 'schema-job-1',
|
||
includeDropIfExists: false,
|
||
}),
|
||
);
|
||
expect(renderer.root.findAllByType(Checkbox).filter((node) => (
|
||
node.props['data-export-include-database-context'] === 'true'
|
||
))).toHaveLength(0);
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('auto-starts a preselected batch object backup inside the workbench', async () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
});
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({ success: true, data: [{ Database: 'SYS' }] } as any);
|
||
vi.mocked(DBGetTables).mockResolvedValue({ success: true, data: [{ name: 'users' }, { name: 'orders' }] } as any);
|
||
vi.mocked(ExportTablesSQLWithOptions).mockResolvedValue({ success: true } as any);
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-batch-tables-conn-1-SYS',
|
||
title: '备份已选对象',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'batch-tables',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableExportInitialObjectNames: ['users', 'orders'],
|
||
tableExportContentMode: 'backup',
|
||
tableExportRequestKey: 'batch-objects-1',
|
||
}}
|
||
/>,
|
||
);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
|
||
const run = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
|
||
await run('batch-objects-job-1');
|
||
|
||
expect(ExportTablesSQLWithOptions).toHaveBeenCalledWith(
|
||
expect.objectContaining({ type: 'mysql' }),
|
||
'SYS',
|
||
['users', 'orders'],
|
||
true,
|
||
true,
|
||
expect.objectContaining({ jobId: 'batch-objects-job-1' }),
|
||
);
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('refreshes launch-only table presets without auto-starting an export', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Database: 'SYS' }, { Database: 'audit' }],
|
||
} as any);
|
||
vi.mocked(DBGetTables).mockResolvedValue({
|
||
success: true,
|
||
data: [{ name: 'users' }, { name: 'orders' }],
|
||
} as any);
|
||
|
||
const buildTab = (
|
||
launchKey: string,
|
||
database: string,
|
||
tableName: string,
|
||
contentMode: 'schema' | 'backup',
|
||
includeDropIfExists: boolean,
|
||
) => ({
|
||
id: 'table-export-batch-tables-conn-1',
|
||
title: '导出已选对象',
|
||
type: 'table-export' as const,
|
||
exportWorkbenchMode: 'batch-tables' as const,
|
||
connectionId: 'conn-1',
|
||
dbName: database,
|
||
tableExportInitialObjectNames: [tableName],
|
||
tableExportContentMode: contentMode,
|
||
tableExportIncludeDropIfExists: includeDropIfExists,
|
||
tableExportLaunchKey: launchKey,
|
||
});
|
||
const readConfig = (renderer: ReactTestRenderer) => {
|
||
const selects = renderer.root.findAllByType(Select);
|
||
const objectSelect = selects.find((node) => node.props.mode === 'multiple');
|
||
const contentModeSelect = selects.find((node) => (
|
||
Array.isArray(node.props.options)
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'dataOnly')
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'backup')
|
||
));
|
||
const dropIfExistsCheckbox = renderer.root.findAllByType(Checkbox)[0];
|
||
return { objectSelect, contentModeSelect, dropIfExistsCheckbox };
|
||
};
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench tab={buildTab('launch-1', 'SYS', 'users', 'schema', false)} />,
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
let config = readConfig(renderer);
|
||
expect(config.objectSelect?.props.value).toEqual(['users']);
|
||
expect(config.contentModeSelect?.props.value).toBe('schema');
|
||
expect(config.dropIfExistsCheckbox?.props.checked).toBe(false);
|
||
expect(mockRunExportWithProgress).not.toHaveBeenCalled();
|
||
|
||
await act(async () => {
|
||
renderer.update(
|
||
<TableExportWorkbench tab={buildTab('launch-2', 'audit', 'orders', 'backup', true)} />,
|
||
);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
config = readConfig(renderer);
|
||
expect(config.objectSelect?.props.value).toEqual(['orders']);
|
||
expect(config.contentModeSelect?.props.value).toBe('backup');
|
||
expect(config.dropIfExistsCheckbox?.props.checked).toBe(true);
|
||
expect(mockUseExportProgressRunner).toHaveBeenLastCalledWith({
|
||
taskKey: 'table-export-batch-tables-conn-1',
|
||
requestKey: undefined,
|
||
});
|
||
expect(mockRunExportWithProgress).not.toHaveBeenCalled();
|
||
expect(ExportTablesSQLWithOptions).not.toHaveBeenCalled();
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('switches a reused table workbench from auto-start to review-only without restarting', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Database: 'SYS' }],
|
||
} as any);
|
||
vi.mocked(DBGetTables).mockResolvedValue({
|
||
success: true,
|
||
data: [{ name: 'users' }, { name: 'orders' }],
|
||
} as any);
|
||
const autoTab = buildBatchTableExportWorkbenchTab({
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
initialObjectNames: ['users'],
|
||
contentMode: 'dataOnly',
|
||
includeDropIfExists: true,
|
||
requestKey: 'request-1',
|
||
});
|
||
const reviewTab = buildBatchTableExportWorkbenchTab({
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
initialObjectNames: ['orders'],
|
||
contentMode: 'backup',
|
||
includeDropIfExists: false,
|
||
launchKey: 'launch-2',
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(<TableExportWorkbench tab={autoTab} />);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
|
||
|
||
await act(async () => {
|
||
renderer.update(<TableExportWorkbench tab={{ ...autoTab, ...reviewTab }} />);
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const selects = renderer.root.findAllByType(Select);
|
||
const objectSelect = selects.find((node) => node.props.mode === 'multiple');
|
||
const contentModeSelect = selects.find((node) => (
|
||
Array.isArray(node.props.options)
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'dataOnly')
|
||
&& node.props.options.some((option: { value?: string }) => option.value === 'backup')
|
||
));
|
||
expect(objectSelect?.props.value).toEqual(['orders']);
|
||
expect(contentModeSelect?.props.value).toBe('backup');
|
||
expect(renderer.root.findAllByType(Checkbox)[0]?.props.checked).toBe(false);
|
||
expect(mockUseExportProgressRunner).toHaveBeenLastCalledWith({
|
||
taskKey: autoTab.id,
|
||
requestKey: undefined,
|
||
});
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
|
||
expect(ExportTablesSQLWithOptions).not.toHaveBeenCalled();
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('reuses a stable task key and applies merged launch options before restarting', async () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
title: '',
|
||
targetName: '',
|
||
format: '',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'idle',
|
||
stage: '',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
});
|
||
mockStoreState = {
|
||
...createMockStoreState(),
|
||
connections: [
|
||
...createMockStoreState().connections,
|
||
{
|
||
id: 'conn-2',
|
||
name: '分析库',
|
||
config: {
|
||
type: 'postgres',
|
||
host: 'analytics.local',
|
||
port: 5432,
|
||
user: 'postgres',
|
||
database: 'analytics',
|
||
},
|
||
},
|
||
],
|
||
};
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Database: 'SYS' }, { Database: 'audit' }],
|
||
} as any);
|
||
|
||
const buildTab = (
|
||
requestKey: string,
|
||
database: string,
|
||
contentMode: 'schema' | 'backup',
|
||
includeDropIfExists: boolean,
|
||
connectionId: string,
|
||
) => ({
|
||
id: 'table-export-batch-databases-conn-1',
|
||
title: '批量导出库',
|
||
type: 'table-export' as const,
|
||
exportWorkbenchMode: 'batch-databases' as const,
|
||
connectionId,
|
||
tableExportInitialDatabaseNames: [database],
|
||
tableExportContentMode: contentMode,
|
||
tableExportIncludeDropIfExists: includeDropIfExists,
|
||
tableExportLaunchKey: 'stale-launch',
|
||
tableExportRequestKey: requestKey,
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(<TableExportWorkbench tab={buildTab('request-1', 'SYS', 'schema', false, 'conn-2')} />);
|
||
await Promise.resolve();
|
||
});
|
||
expect(renderer.root.findAllByProps({ 'data-export-include-database-context': 'true' })).toHaveLength(0);
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(1);
|
||
const firstRun = mockRunExportWithProgress.mock.calls[0][0].run as (jobId: string) => Promise<unknown>;
|
||
await firstRun('batch-databases-job-1');
|
||
expect(ExportDatabasesSQLWithOptions).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ type: 'postgres' }),
|
||
['SYS'],
|
||
false,
|
||
expect.objectContaining({
|
||
includeDropIfExists: false,
|
||
includeDatabaseContext: false,
|
||
}),
|
||
);
|
||
|
||
await act(async () => {
|
||
renderer.update(<TableExportWorkbench tab={buildTab('request-2', 'audit', 'backup', true, 'conn-1')} />);
|
||
await Promise.resolve();
|
||
});
|
||
expect(renderer.root.findAllByType(Checkbox).filter((node) => (
|
||
node.props['data-export-include-database-context'] === 'true'
|
||
))).toHaveLength(1);
|
||
|
||
expect(mockUseExportProgressRunner).toHaveBeenCalledWith({
|
||
taskKey: 'table-export-batch-databases-conn-1',
|
||
requestKey: 'request-2',
|
||
});
|
||
expect(mockRunExportWithProgress).toHaveBeenCalledTimes(2);
|
||
const secondRun = mockRunExportWithProgress.mock.calls[1][0].run as (jobId: string) => Promise<unknown>;
|
||
await secondRun('batch-databases-job-2');
|
||
|
||
expect(ExportDatabasesSQLWithOptions).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ type: 'mysql' }),
|
||
['audit'],
|
||
true,
|
||
expect.objectContaining({
|
||
includeDropIfExists: true,
|
||
includeDatabaseContext: true,
|
||
}),
|
||
);
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('resets the database context default when a stable database workbench is reopened', async () => {
|
||
mockProgressRunnerState = createIdleProgressRunnerState();
|
||
vi.mocked(DBGetDatabases).mockResolvedValue({
|
||
success: true,
|
||
data: [{ Database: 'SYS' }, { Database: 'audit' }],
|
||
} as any);
|
||
|
||
const buildTab = (launchKey: string, contentMode: 'schema' | 'backup') => ({
|
||
id: 'table-export-batch-databases-conn-1',
|
||
title: '批量导出库',
|
||
type: 'table-export' as const,
|
||
exportWorkbenchMode: 'batch-databases' as const,
|
||
connectionId: 'conn-1',
|
||
tableExportInitialDatabaseNames: ['SYS'],
|
||
tableExportContentMode: contentMode,
|
||
tableExportLaunchKey: launchKey,
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(<TableExportWorkbench tab={buildTab('launch-1', 'backup')} />);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
const findDatabaseContextCheckbox = () => renderer.root.findByProps({
|
||
'data-export-include-database-context': 'true',
|
||
});
|
||
expect(findDatabaseContextCheckbox().props.checked).toBe(true);
|
||
|
||
await act(async () => {
|
||
renderer.update(<TableExportWorkbench tab={buildTab('launch-2', 'schema')} />);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(findDatabaseContextCheckbox().props.checked).toBe(false);
|
||
expect(mockRunExportWithProgress).not.toHaveBeenCalled();
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('renders retained task logs in the current task panel', () => {
|
||
mockProgressLogs = [
|
||
{
|
||
sequence: 1,
|
||
timestamp: 1_000,
|
||
jobId: 'job-1',
|
||
source: 'client',
|
||
status: 'start',
|
||
stage: '等待选择导出文件',
|
||
current: 0,
|
||
total: 0,
|
||
totalRowsKnown: false,
|
||
filePath: '',
|
||
message: '',
|
||
},
|
||
{
|
||
sequence: 2,
|
||
timestamp: 2_000,
|
||
jobId: 'job-1',
|
||
source: 'backend',
|
||
status: 'running',
|
||
stage: '正在导出 users (1/2)',
|
||
current: 1,
|
||
total: 2,
|
||
totalRowsKnown: true,
|
||
filePath: '/tmp/app_backup.sql',
|
||
message: '',
|
||
},
|
||
];
|
||
|
||
const markup = renderToStaticMarkup(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-database-conn-1-SYS',
|
||
title: '备份 SYS',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'database',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableExportContentMode: 'backup',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
expect(markup).toContain('data-export-workbench-logs="true"');
|
||
expect(markup).toContain('等待选择导出文件');
|
||
expect(markup).toContain('正在导出 users (1/2)');
|
||
});
|
||
|
||
it('opens a completed SQL backup in the restore workbench without auto-running it', async () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: true,
|
||
jobId: 'database-backup-job-1',
|
||
title: '备份 SYS',
|
||
targetName: 'SYS',
|
||
format: 'SQL',
|
||
startedAt: 1_000,
|
||
finishedAt: 3_000,
|
||
status: 'done',
|
||
stage: '导出完成',
|
||
current: 2,
|
||
total: 2,
|
||
totalRowsKnown: true,
|
||
filePath: '/tmp/SYS_backup.sql',
|
||
message: '',
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-database-conn-1-SYS',
|
||
title: '备份 SYS',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'database',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableExportContentMode: 'backup',
|
||
}}
|
||
/>,
|
||
);
|
||
});
|
||
|
||
const restoreButton = renderer.root.findAllByType(Button).find((node) => (
|
||
node.props['data-export-restore-backup'] === true
|
||
));
|
||
expect(restoreButton).toBeDefined();
|
||
await act(async () => {
|
||
restoreButton?.props.onClick();
|
||
});
|
||
|
||
expect(mockAddTab).toHaveBeenCalledWith(expect.objectContaining({
|
||
id: 'sql-file-execution-conn-1-SYS-/tmp/SYS_backup.sql',
|
||
type: 'sql-file-execution',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
filePath: '/tmp/SYS_backup.sql',
|
||
}));
|
||
expect(mockAddTab.mock.calls[0][0]).not.toHaveProperty('sqlFileExecutionRequestKey');
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('keeps completed SQL backups restorable from task history', async () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: false,
|
||
jobId: '',
|
||
status: 'idle',
|
||
filePath: '',
|
||
});
|
||
(mockStoreState as any).tableExportHistories = {
|
||
'conn-1::SYS::__database__': [{
|
||
jobId: 'historical-backup-1',
|
||
targetName: 'SYS',
|
||
startedAt: 1_000,
|
||
finishedAt: 3_000,
|
||
format: 'SQL',
|
||
scope: 'selectedDatabases',
|
||
scopeLabel: 'SYS',
|
||
strategyLabel: '备份',
|
||
status: 'done',
|
||
stage: '导出完成',
|
||
current: 2,
|
||
total: 2,
|
||
totalRowsKnown: true,
|
||
filePath: '/tmp/SYS_history_backup.sql',
|
||
message: '',
|
||
}],
|
||
};
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-database-conn-1-SYS',
|
||
title: '备份 SYS',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'database',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableExportContentMode: 'backup',
|
||
}}
|
||
/>,
|
||
);
|
||
});
|
||
|
||
const restoreButton = renderer.root.findAllByType(Button).find((node) => (
|
||
node.props['data-export-history-restore'] === 'historical-backup-1'
|
||
));
|
||
expect(restoreButton).toBeDefined();
|
||
await act(async () => {
|
||
restoreButton?.props.onClick();
|
||
});
|
||
expect(mockAddTab).toHaveBeenCalledWith(expect.objectContaining({
|
||
type: 'sql-file-execution',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
filePath: '/tmp/SYS_history_backup.sql',
|
||
}));
|
||
expect(mockAddTab.mock.calls[0][0]).not.toHaveProperty('sqlFileExecutionRequestKey');
|
||
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('does not persist a task before the backend has reached a terminal state', async () => {
|
||
mockProgressRunnerState = createProgressRunnerState({
|
||
open: true,
|
||
jobId: 'pending-file-selection',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
status: 'start',
|
||
stage: '等待选择导出文件',
|
||
filePath: '',
|
||
});
|
||
|
||
let renderer!: ReactTestRenderer;
|
||
await act(async () => {
|
||
renderer = create(
|
||
<TableExportWorkbench
|
||
tab={{
|
||
id: 'table-export-database-conn-1-SYS',
|
||
title: '备份 SYS',
|
||
type: 'table-export',
|
||
exportWorkbenchMode: 'database',
|
||
connectionId: 'conn-1',
|
||
dbName: 'SYS',
|
||
tableExportContentMode: 'backup',
|
||
}}
|
||
/>,
|
||
);
|
||
});
|
||
|
||
expect(mockUpsertTableExportHistory).not.toHaveBeenCalled();
|
||
renderer.unmount();
|
||
});
|
||
|
||
it('offers DROP IF EXISTS only for SQL exports that include schema', () => {
|
||
const source = readFileSync(new URL('./TableExportWorkbench.tsx', import.meta.url), 'utf8');
|
||
expect(source.match(/disabled=\{isConfigurationLocked\}/g)?.length || 0).toBeGreaterThanOrEqual(10);
|
||
});
|
||
|
||
it('prefers backend startedAt over a placeholder history timestamp for the same job', () => {
|
||
const entry = buildTableExportHistoryEntry({
|
||
progressState: {
|
||
...createMockProgressRunnerState(),
|
||
startedAt: 8_000,
|
||
stage: '正在准备导出',
|
||
filePath: '/Users/yangguofeng/Desktop/SYS.test.xlsx',
|
||
},
|
||
existingEntry: {
|
||
jobId: 'job-1',
|
||
targetName: 'SYS.test',
|
||
startedAt: 0,
|
||
finishedAt: 0,
|
||
format: 'XLSX',
|
||
scope: 'all',
|
||
scopeLabel: '全表数据',
|
||
strategyLabel: '整表导出链路',
|
||
status: 'start',
|
||
stage: '等待选择导出文件',
|
||
current: 0,
|
||
total: 500_000,
|
||
totalRowsKnown: true,
|
||
filePath: '',
|
||
message: '',
|
||
},
|
||
fallbackTargetName: 'SYS.test',
|
||
fallbackFormat: 'XLSX',
|
||
scope: 'all',
|
||
scopeLabel: '全表数据',
|
||
strategyLabel: '整表导出链路',
|
||
});
|
||
|
||
expect(entry.startedAt).toBe(8_000);
|
||
expect(entry.filePath).toBe('/Users/yangguofeng/Desktop/SYS.test.xlsx');
|
||
});
|
||
});
|