feat(dbms): 支持MySQL数据库连接管理功能
- 添加MySQL数据库类型的连接支持,默认端口为3306,用户名为root - 实现Oracle与MySQL连接类型的差异化表单验证逻辑 - 更新连接树组件以区分不同数据库类型的显示方式 - 集成备份恢复任务面板,支持双标签页切换功能 - 优化表格排序功能,增强连接列表的可操作性 - 调整任务面板布局,改进用户体验和界面交互 - 新增连接对话框中数据库类型的选择与初始化逻辑
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||
|
||||
const apiSource = fs.readFileSync(path.join(root, '../../../api/system/dbms/index.ts'), 'utf8')
|
||||
const apiTypesSource = fs.readFileSync(path.join(root, '../../../api/system/dbms/interface/index.ts'), 'utf8')
|
||||
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||
const statusCardSource = read('components/DbmsTaskStatusCard.vue')
|
||||
const pageSource = read('index.vue')
|
||||
|
||||
const checks = [
|
||||
['api should expose stop backup task endpoint', /stopDbmsBackupTask[\s\S]*\/database\/backups\/tasks\/stop/.test(apiSource)],
|
||||
['api should expose restart backup task endpoint', /restartDbmsBackupTask[\s\S]*\/database\/backups\/tasks\/restart/.test(apiSource)],
|
||||
['api types should define stop backup task params', /interface StopBackupTaskParams[\s\S]*taskId:\s*string/.test(apiTypesSource)],
|
||||
['api types should define restart backup task params', /interface RestartBackupTaskParams[\s\S]*taskId:\s*string/.test(apiTypesSource)],
|
||||
['backup file type should include metadataFilePath', /metadataFilePath\?:\s*string \| null/.test(apiTypesSource)],
|
||||
['backup targetNames should be required by form rules', /targetNames:\s*\[\{ required:\s*true/.test(taskPanelSource)],
|
||||
['backup table selection form item should bind targetNames prop', /label="表选择"[\s\S]*prop="targetNames"/.test(taskPanelSource)],
|
||||
['task status card should emit stop task', /'stop-task':\s*\[row:\s*Dbms\.TaskRecord\]/.test(statusCardSource)],
|
||||
['task status card should emit restart task', /'restart-task':\s*\[row:\s*Dbms\.TaskRecord\]/.test(statusCardSource)],
|
||||
['task status card should render disabled stop action for unavailable backup tasks', /:disabled="!canStopTask\(row\)"[\s\S]*emit\('stop-task', row\)/.test(statusCardSource)],
|
||||
['task status card should render disabled restart action for unavailable backup tasks', /:disabled="!canRestartTask\(row\)"[\s\S]*emit\('restart-task', row\)/.test(statusCardSource)],
|
||||
['backup file dialog should hide metadata path', !/label:\s*'metadata'[\s\S]*file\.metadataFilePath/.test(statusCardSource)],
|
||||
['backup file dialog should hide checksum', !/label:\s*'checksum'[\s\S]*file\.checksum/.test(statusCardSource)],
|
||||
['backup file dialog should hide log file', !/label:\s*'日志文件'[\s\S]*file\.logFileName/.test(statusCardSource)],
|
||||
['page should handle stop task event', /@stop-task="handleStopTask"/.test(pageSource)],
|
||||
['page should handle restart task event', /@restart-task="handleRestartTask"/.test(pageSource)]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms backup restore api debug contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms backup restore api debug contract passed')
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const indexSource = fs.readFileSync(path.join(root, 'index.vue'), 'utf8')
|
||||
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||
const workspaceSource = fs.readFileSync(path.join(root, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||
|
||||
const backupHandlerMatch = indexSource.match(
|
||||
/const handleCreateBackup = async \([\s\S]*?\n}\n\nconst handleCreateRestore/
|
||||
)
|
||||
const backupHandlerSource = backupHandlerMatch?.[0] || ''
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'backup creation should keep user on backup panel instead of opening tasks section',
|
||||
backupHandlerSource && !/activeSection\.value\s*=\s*['"]tasks['"]/.test(backupHandlerSource)
|
||||
],
|
||||
[
|
||||
'backup panel should embed task records for same-page feedback',
|
||||
/<DbmsTaskStatusCard[\s\S]*class="embedded-status-card"[\s\S]*:tasks="currentTasks"/.test(taskPanelSource) &&
|
||||
/currentTasks[\s\S]*activeTab\.value === 'restore' \? props\.restoreTasks : props\.tasks/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup panel should own connection info area to free workspace header space',
|
||||
/v-if="activeSection !== 'backup'"[\s\S]*class="workspace-header"/.test(workspaceSource) &&
|
||||
/class="task-panel-connection"/.test(taskPanelSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms backup task same-page contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms backup task same-page contract passed')
|
||||
@@ -11,7 +11,10 @@ const checks = [
|
||||
['dialog uses compact Navicat-like width', /width="630px"/.test(dialogSource)],
|
||||
['dialog uses shared runtime size class', /class="dbms-connection-size-dialog"/.test(dialogSource)],
|
||||
['dialog renders connection flow header', /class="connection-flow"/.test(dialogSource)],
|
||||
['dialog keeps Basic connection type visible', /model-value="Basic"/.test(dialogSource)],
|
||||
[
|
||||
'dialog keeps Basic connection type visible only for Oracle',
|
||||
/el-form-item\s+v-if="selectedDbType === 'ORACLE'"\s+label="连接类型"[\s\S]*model-value="Basic"/.test(dialogSource)
|
||||
],
|
||||
[
|
||||
'dialog exposes service name and SID radio choices',
|
||||
/el-radio[\s\S]*SERVICE_NAME[\s\S]*el-radio[\s\S]*SID/.test(dialogSource)
|
||||
@@ -33,6 +36,20 @@ const checks = [
|
||||
[
|
||||
'new Oracle connection defaults service name to ORCL',
|
||||
/serviceName:\s*resolveText\(record\?\.serviceName\)\s*\|\|\s*'ORCL'/.test(payloadSource)
|
||||
],
|
||||
[
|
||||
'new MySQL connection defaults port to 3306',
|
||||
/port:\s*Number\(record\?\.port\)\s*\|\|\s*\(dbType\s*===\s*'MYSQL'\s*\?\s*3306\s*:\s*1521\)/.test(payloadSource)
|
||||
],
|
||||
[
|
||||
'new MySQL connection defaults username to root',
|
||||
/username:\s*resolveText\(record\?\.username\)\s*\|\|\s*\(dbType\s*===\s*'MYSQL'\s*\?\s*'root'\s*:\s*''\)/.test(
|
||||
payloadSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'connection form open initializes with selected database type defaults',
|
||||
/applyForm\(createConnectionForm\(record,\s*selectedDbType\.value\)\)/.test(dialogSource)
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.join(currentDir, '..')
|
||||
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||
|
||||
const treeSource = read('components/DbmsConnectionTree.vue')
|
||||
|
||||
const checks = [
|
||||
['connection tree uses real connection name as root label', /label:\s*connection\.connectionName/.test(treeSource)],
|
||||
[
|
||||
'connection tree uses databaseName for MySQL child label',
|
||||
/connection\.dbType\s*===\s*'MYSQL'\s*\?\s*connection\.databaseName\?\.trim\(\)/.test(treeSource)
|
||||
],
|
||||
['connection tree does not hardcode table placeholder node', !/label:\s*'表'/.test(treeSource)],
|
||||
['connection tree does not hardcode view placeholder node', !/label:\s*'视图'/.test(treeSource)],
|
||||
['connection tree does not build fake table group type', !/type:\s*'tableGroup'/.test(treeSource)],
|
||||
['connection tree does not build fake view group type', !/type:\s*'viewGroup'/.test(treeSource)]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('dbms connection tree data contract failed:')
|
||||
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms connection tree data contract passed')
|
||||
@@ -51,10 +51,7 @@ const checks = [
|
||||
['selector content fills dialog body', /connection-type-dialog[\s\S]*flex:\s*1/.test(read(files.selector))],
|
||||
['selector only exposes Oracle and MySQL', /type:\s*'ORACLE'[\s\S]*type:\s*'MYSQL'/.test(read(files.selector))],
|
||||
['selector keeps next action explicit', /emit\('next',\s*selectedType\.value\)/.test(read(files.selector))],
|
||||
[
|
||||
'page blocks MySQL until backend is available',
|
||||
/if\s*\(dbType\s*===\s*'MYSQL'\)[\s\S]*MySQL 连接配置暂未接入/.test(read(files.page))
|
||||
],
|
||||
['page allows MySQL connection type selection', !/if\s*\(dbType\s*===\s*'MYSQL'\)/.test(read(files.page))],
|
||||
['connection form displays selected database type', /selectedDbType/.test(read(files.connectionDialog))],
|
||||
[
|
||||
'connection form open accepts selected database type',
|
||||
|
||||
@@ -7,12 +7,15 @@ const pageDir = path.join(currentDir, '..')
|
||||
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||
|
||||
const pageSource = read('index.vue')
|
||||
const apiSource = fs.readFileSync(path.join(pageDir, '../../../api/index.ts'), 'utf8')
|
||||
|
||||
const onMountedBlock = pageSource.match(/onMounted\(\(\)\s*=>\s*\{[\s\S]*?\n\}\)/)?.[0] ?? ''
|
||||
|
||||
const checks = [
|
||||
['page should not auto load dbms overview on menu open', !onMountedBlock.includes('loadOverview()')],
|
||||
['page should not auto load dbms connections on menu open', !onMountedBlock.includes('loadConnections()')],
|
||||
['page should refresh dbms connections when connection tree initializes', onMountedBlock.includes('initializeConnectionTree()')],
|
||||
['initial connection tree refresh should use guarded initializer', /const initializeConnectionTree = async \(\) => \{[\s\S]*await loadConnections\(\)/.test(pageSource)],
|
||||
['silent initial refresh should suppress business 404 message', /data\.code && data\.code !== ResultEnum\.SUCCESS[\s\S]*silentStatusError[\s\S]*return Promise\.reject\(data\)/.test(apiSource)],
|
||||
['page should not auto load dbms tasks on menu open', !onMountedBlock.includes('loadTasks()')],
|
||||
['page should not auto load dbms files on menu open', !onMountedBlock.includes('loadFiles()')],
|
||||
['connection tree keeps manual refresh entry', /<DbmsConnectionTree[\s\S]*@refresh="loadConnections"/.test(pageSource)]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
const files = {
|
||||
page: path.join(root, 'index.vue'),
|
||||
dialog: path.join(root, 'components/DbmsConnectionDialog.vue'),
|
||||
taskPanel: path.join(root, 'components/DbmsTaskPanel.vue'),
|
||||
payload: path.join(root, 'utils/taskPayload.ts'),
|
||||
apiTypes: path.join(root, '../../../api/system/dbms/interface/index.ts')
|
||||
}
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
|
||||
const checks = [
|
||||
['connection list should not hardcode ORACLE dbType', !/dbType:\s*'ORACLE'/.test(read(files.page))],
|
||||
['mysql entry should not be blocked in connection type selection', !/if\s*\(dbType\s*===\s*'MYSQL'\)/.test(read(files.page))],
|
||||
['connection payload type should define databaseName', /databaseName\?:\s*string \| null/.test(read(files.apiTypes))],
|
||||
['connection form model should define databaseName', /databaseName:\s*string/.test(read(files.payload))],
|
||||
['payload builder should map MySQL databaseName', /databaseName:\s*dbType === 'MYSQL' \? form\.databaseName\.trim\(\) \|\| null : null/.test(read(files.payload))],
|
||||
[
|
||||
'backup file list should support MySQL JDBC_EXPORT strategy',
|
||||
/backupStrategy:\s*backupStrategy \|\| undefined/.test(read(files.payload))
|
||||
],
|
||||
[
|
||||
'mysql backup payload should prefer databaseName as schemaName fallback',
|
||||
/connection\.dbType === 'MYSQL'[\s\S]*connection\.databaseName[\s\S]*connection\.schemaName/.test(read(files.payload))
|
||||
],
|
||||
[
|
||||
'mysql task panel should lock backup strategy to JDBC_EXPORT',
|
||||
/isMysqlConnection[\s\S]*backupForm\.backupStrategy = 'JDBC_EXPORT'/.test(read(files.taskPanel))
|
||||
],
|
||||
[
|
||||
'task panel should hide Data Pump Directory field',
|
||||
!/label="Directory"[\s\S]*v-model="backupForm\.directoryName"/.test(read(files.taskPanel))
|
||||
],
|
||||
[
|
||||
'mysql task panel should label schema input as database name',
|
||||
/:label="schemaFieldLabel"/.test(read(files.taskPanel))
|
||||
],
|
||||
['connection dialog should render databaseName field for MySQL', /v-if="selectedDbType === 'MYSQL'"/.test(read(files.dialog))]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms mysql api debug contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms mysql api debug contract passed')
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||
|
||||
const pageSource = read('index.vue')
|
||||
const apiDebugSource = read('API_DEBUG.md')
|
||||
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||
const payloadSource = read('utils/taskPayload.ts')
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'backup strategy select should be locked for all connections',
|
||||
/<el-select\s+v-model="backupForm\.backupStrategy"\s+disabled>/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'task panel should not render Oracle Directory field for backup',
|
||||
!/label="Directory"[\s\S]*v-model="backupForm\.directoryName"/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'connection watcher should always set JDBC_EXPORT',
|
||||
/backupForm\.backupStrategy\s*=\s*'JDBC_EXPORT'/.test(taskPanelSource) &&
|
||||
!/backupForm\.backupStrategy\s*=\s*'DATA_PUMP'/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup payload should always send JDBC_EXPORT',
|
||||
/backupStrategy:\s*'JDBC_EXPORT'/.test(payloadSource) && !/backupStrategy:\s*connection\.dbType/.test(payloadSource)
|
||||
],
|
||||
[
|
||||
'JDBC_EXPORT backup payload should not send Data Pump directoryName',
|
||||
/directoryName:\s*null/.test(payloadSource) && !/directoryName:\s*connection\.dbType === 'ORACLE'/.test(payloadSource)
|
||||
],
|
||||
[
|
||||
'backup file list should always query JDBC_EXPORT files',
|
||||
/buildFileListParams\([\s\S]*fileQuery\.taskId,\s*'JDBC_EXPORT'[\s\S]*\)/.test(pageSource) &&
|
||||
!/selectedConnection\.value\?\.dbType === 'MYSQL' \? 'JDBC_EXPORT' : 'DATA_PUMP'/.test(pageSource)
|
||||
],
|
||||
[
|
||||
'api debug should document unified JDBC_EXPORT strategy',
|
||||
/当前前端统一使用 `JDBC_EXPORT`/.test(apiDebugSource) &&
|
||||
/Oracle JDBC_EXPORT 备份/.test(apiDebugSource) &&
|
||||
!/Oracle Data Pump 备份|MySQL 仅支持 `JDBC_EXPORT`|MySQL 大数据量 JDBC_EXPORT/.test(apiDebugSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms oracle jdbc export contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms oracle jdbc export contract passed')
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||
|
||||
const apiSource = read('../../../api/system/dbms/index.ts')
|
||||
const pageSource = read('index.vue')
|
||||
const workspaceSource = read('components/DbmsWorkspace.vue')
|
||||
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'api should expose restore task list endpoint',
|
||||
/getDbmsRestoreTaskList[\s\S]*\/database\/restores\/tasks\/list/.test(apiSource)
|
||||
],
|
||||
[
|
||||
'page should keep restore task state separate from backup tasks',
|
||||
/const restoreTasks = ref<Dbms\.TaskRecord\[\]>\(\[\]\)/.test(pageSource) &&
|
||||
/const restoreTaskTotal = ref\(0\)/.test(pageSource) &&
|
||||
/const restoreTaskPage = reactive\(\{ pageNum: 1, pageSize: 10 \}\)/.test(pageSource) &&
|
||||
/const restoreTaskQuery = reactive<DbmsTaskQuery>\(\{ taskStatus: '' \}\)/.test(pageSource)
|
||||
],
|
||||
[
|
||||
'page should load restore task list from restore endpoint',
|
||||
/const loadRestoreTasks = async \(\) => \{[\s\S]*getDbmsRestoreTaskList[\s\S]*restoreTasks\.value = result\.data\.records \|\| \[\][\s\S]*restoreTaskTotal\.value = result\.data\.total \|\| 0[\s\S]*\}/.test(
|
||||
pageSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'workspace should pass restore task props into backup and restore panel',
|
||||
/:restore-tasks="restoreTasks"/.test(workspaceSource) &&
|
||||
/:restore-task-query="restoreTaskQuery"/.test(workspaceSource) &&
|
||||
/:restore-task-total="restoreTaskTotal"/.test(workspaceSource) &&
|
||||
/:restore-task-page-num="restoreTaskPageNum"/.test(workspaceSource) &&
|
||||
/:restore-task-page-size="restoreTaskPageSize"/.test(workspaceSource)
|
||||
],
|
||||
[
|
||||
'task panel should select restore task records when restore tab is active',
|
||||
/const currentTasks = computed\(\(\) => \(activeTab\.value === 'restore' \? props\.restoreTasks : props\.tasks\)\)/.test(
|
||||
taskPanelSource
|
||||
) &&
|
||||
/:tasks="currentTasks"/.test(taskPanelSource) &&
|
||||
/@refresh-tasks="handleRefreshCurrentTasks"/.test(taskPanelSource) &&
|
||||
/@search-tasks="handleSearchCurrentTasks"/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'restore creation should refresh restore task records without opening outer task page',
|
||||
/const handleCreateRestore = async[\s\S]*await loadRestoreTasks\(\)[\s\S]*pollTaskStatus\(result\.data\.taskId, 'RESTORE'\)/.test(
|
||||
pageSource
|
||||
) && !/const handleCreateRestore = async[\s\S]*activeSection\.value\s*=\s*'tasks'/.test(pageSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms restore task list contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms restore task list contract passed')
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const read = relativePath => fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||
|
||||
const taskPanelSource = read('components/DbmsTaskPanel.vue')
|
||||
const statusCardSource = read('components/DbmsTaskStatusCard.vue')
|
||||
const workspaceSource = read('components/DbmsWorkspace.vue')
|
||||
|
||||
const tableStart = statusCardSource.indexOf('<el-table :data="tasks"')
|
||||
const tableEnd = statusCardSource.indexOf('</el-table>', tableStart)
|
||||
const taskTableSource = statusCardSource.slice(tableStart, tableEnd)
|
||||
const restoreStart = taskTableSource.indexOf('<template v-if="operationType === \'RESTORE\'">')
|
||||
const backupStart = taskTableSource.indexOf('<template v-else>', restoreStart)
|
||||
const restoreTableSource = taskTableSource.slice(restoreStart, backupStart)
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'task panel should pass active operation type into embedded task table',
|
||||
/:operation-type="currentOperationType"/.test(taskPanelSource) &&
|
||||
/const currentOperationType = computed\(\(\) => \(activeTab\.value === 'restore' \? 'RESTORE' : 'BACKUP'\)\)/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
['task status card should accept operation type prop', /operationType: Dbms\.OperationType/.test(statusCardSource)],
|
||||
[
|
||||
'outer task status card should keep backup operation type',
|
||||
/<DbmsTaskStatusCard[\s\S]*operation-type="BACKUP"[\s\S]*:tasks="tasks"/.test(workspaceSource)
|
||||
],
|
||||
[
|
||||
'restore task table should use restore-specific columns instead of the backup task column structure',
|
||||
/prop="taskNo"/.test(restoreTableSource) &&
|
||||
/prop="schemaName"/.test(restoreTableSource) &&
|
||||
/formatRestoreMode\(row\.restoreMode\)/.test(restoreTableSource) &&
|
||||
!/parseJsonArrayText\(row\.targetNamesJson\)/.test(restoreTableSource) &&
|
||||
!/resolveTaskFilePath\(row\)/.test(restoreTableSource)
|
||||
],
|
||||
['restore task table should keep a check status action', /emit\('check-task', row\)/.test(restoreTableSource)],
|
||||
[
|
||||
'restore task table should not render backup stop and restart actions',
|
||||
!/emit\('stop-task', row\)/.test(restoreTableSource) && !/emit\('restart-task', row\)/.test(restoreTableSource)
|
||||
],
|
||||
['restore task table action column should shrink', /width="150" fixed="right"/.test(restoreTableSource)]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms restore task table display contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms restore task table display contract passed')
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.resolve(__dirname, '..')
|
||||
const workspaceSource = fs.readFileSync(path.join(pageDir, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||
|
||||
const hasExpectedWidth = /label="自动递增值"[^>]*width="130"/.test(workspaceSource)
|
||||
|
||||
if (!hasExpectedWidth) {
|
||||
console.error('dbms table auto increment width contract failed:')
|
||||
console.error('- auto increment column width must be 130')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms table auto increment width contract passed')
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.resolve(__dirname, '..')
|
||||
const workspaceSource = fs.readFileSync(path.join(pageDir, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||
|
||||
const centeredColumns = [
|
||||
['autoIncrement', /label="自动递增值"[^>]*align="center"/],
|
||||
['updateTime', /prop="updateTime"[^>]*align="center"/],
|
||||
['dataLength', /label="数据长度"[^>]*align="center"/],
|
||||
['engine', /prop="engine"[^>]*align="center"/],
|
||||
['tableRows', /label="行"[^>]*align="center"/]
|
||||
]
|
||||
|
||||
const errors = centeredColumns
|
||||
.filter(([, pattern]) => !pattern.test(workspaceSource))
|
||||
.map(([column]) => `${column} column must align center`)
|
||||
|
||||
if (errors.length) {
|
||||
console.error('dbms table column align contract failed:')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms table column align contract passed')
|
||||
@@ -0,0 +1,34 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.join(currentDir, '..')
|
||||
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||
|
||||
const workspaceSource = read('components/DbmsWorkspace.vue')
|
||||
const interfaceSource = fs.readFileSync(
|
||||
path.join(pageDir, '..', '..', '..', 'api', 'system', 'dbms', 'interface', 'index.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const checks = [
|
||||
['table list uses Navicat name column label', /label="名称"/.test(workspaceSource)],
|
||||
['table list shows auto increment value', /label="自动递增值"/.test(workspaceSource)],
|
||||
['table list shows update time', /label="修改日期"/.test(workspaceSource)],
|
||||
['table list shows data length', /label="数据长度"/.test(workspaceSource)],
|
||||
['table list shows engine', /label="引擎"/.test(workspaceSource)],
|
||||
['table list shows row count', /label="行"/.test(workspaceSource)],
|
||||
['table list shows comments', /label="注释"/.test(workspaceSource)],
|
||||
['table record type includes optional Navicat-like metadata', /autoIncrement/.test(interfaceSource) && /dataLength/.test(interfaceSource)]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('dbms table list Navicat contract failed:')
|
||||
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms table list Navicat contract passed')
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.resolve(__dirname, '..')
|
||||
const workspaceSource = fs.readFileSync(path.join(pageDir, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||
|
||||
const checks = [
|
||||
['table list has selection column', /<el-table-column\s+type="selection"/.test(workspaceSource)],
|
||||
['table list highlights current row', /<el-table[\s\S]*\bhighlight-current-row\b/.test(workspaceSource)],
|
||||
['table list listens current row changes', /<el-table[\s\S]*@current-change="handleTableCurrentChange"/.test(workspaceSource)],
|
||||
['table list listens multi selection changes', /<el-table[\s\S]*@selection-change="handleTableSelectionChange"/.test(workspaceSource)],
|
||||
['workspace stores selected table', /const selectedTable = ref<Dbms\.TableRecord \| null>\(null\)/.test(workspaceSource)],
|
||||
['workspace stores selected tables', /const selectedTables = ref<Dbms\.TableRecord\[\]>\(\[\]\)/.test(workspaceSource)],
|
||||
['workspace updates selected table', /const handleTableCurrentChange = \(row\?: Dbms\.TableRecord\) => \{[\s\S]*selectedTable\.value = row \|\| null[\s\S]*\}/.test(workspaceSource)],
|
||||
['workspace updates selected tables', /const handleTableSelectionChange = \(rows: Dbms\.TableRecord\[\]\) => \{[\s\S]*selectedTables\.value = rows[\s\S]*\}/.test(workspaceSource)]
|
||||
]
|
||||
|
||||
const errors = checks.filter(([, passed]) => !passed).map(([message]) => message)
|
||||
|
||||
if (errors.length) {
|
||||
console.error('dbms table selection contract failed:')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms table selection contract passed')
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.resolve(__dirname, '..')
|
||||
|
||||
const read = relativePath => fs.readFileSync(path.join(pageDir, relativePath), 'utf8')
|
||||
|
||||
const expectations = [
|
||||
{
|
||||
file: 'components/DbmsOperationTable.vue',
|
||||
columns: [
|
||||
'connectionName',
|
||||
'host',
|
||||
'port',
|
||||
'connectType',
|
||||
'schemaName',
|
||||
'username',
|
||||
'directoryName',
|
||||
'savePassword',
|
||||
'lastTestStatus',
|
||||
'lastTestMessage'
|
||||
]
|
||||
},
|
||||
{
|
||||
file: 'components/DbmsTaskStatusCard.vue',
|
||||
columns: [
|
||||
'taskNo',
|
||||
'operationType',
|
||||
'taskStatus',
|
||||
'progressPercent',
|
||||
'schemaName',
|
||||
'targetNamesJson',
|
||||
'resultMessage',
|
||||
'startedAt',
|
||||
'finishedAt',
|
||||
'fileName',
|
||||
'backupMode',
|
||||
'fileSize',
|
||||
'filePath',
|
||||
'logFileName',
|
||||
'createTime'
|
||||
]
|
||||
},
|
||||
{
|
||||
file: 'components/DbmsWorkspace.vue',
|
||||
columns: ['tableName', 'autoIncrement', 'updateTime', 'dataLength', 'engine', 'tableRows', 'comments']
|
||||
}
|
||||
]
|
||||
|
||||
const errors = []
|
||||
|
||||
for (const { file, columns } of expectations) {
|
||||
const source = read(file)
|
||||
for (const column of columns) {
|
||||
const sortableByProp = new RegExp(`prop="${column}"[^>]*\\bsortable\\b`).test(source)
|
||||
const sortableBySortBy = new RegExp(`sort-by="${column}"[^>]*\\bsortable\\b|\\bsortable\\b[^>]*sort-by="${column}"`).test(source)
|
||||
if (!sortableByProp && !sortableBySortBy) {
|
||||
errors.push(`${file} column ${column} must be sortable`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error('dbms table sortable contract failed:')
|
||||
for (const error of errors) console.error(`- ${error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms table sortable contract passed')
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'backup form grid should render three columns on desktop',
|
||||
/\.form-grid\s*\{[\s\S]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup mode layout should expose classes for time range and size split modes',
|
||||
/backupModeLayoutClass[\s\S]*is-time-range[\s\S]*is-size-split/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup form grid should keep one column on narrow screens',
|
||||
/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*\.form-grid[\s\S]*\{[\s\S]*grid-template-columns:\s*1fr/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'restore form fields should share the same three-column form grid',
|
||||
/<el-tab-pane\s+label="创建恢复"[\s\S]*<div class="restore-form-grid form-grid">[\s\S]*prop="backupFileId"[\s\S]*v-model="restoreForm\.restoreMode"[\s\S]*v-model="restoreForm\.targetSchemaName"[\s\S]*prop="overwriteConfirmText"[\s\S]*<el-checkbox[\s\S]*v-model="overwriteConfirmChecked"[\s\S]*我确认清空或覆盖目标数据[\s\S]*class="restore-table-action"[\s\S]*handleRestoreSubmit[\s\S]*开始恢复[\s\S]*<\/div>[\s\S]*<\/div>/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'restore overwrite checkbox should keep backend confirm text value',
|
||||
/watch\(\s*overwriteConfirmChecked[\s\S]*restoreForm\.overwriteConfirmText\s*=\s*checked\s*\?\s*OVERWRITE_CONFIRM_TEXT\s*:\s*''/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'restore backup select should be derived from successful backup tasks',
|
||||
/const restoreBackupOptions = computed\(\(\) =>[\s\S]*props\.tasks[\s\S]*task\.operationType === 'BACKUP'[\s\S]*task\.taskStatus === 'SUCCESS'[\s\S]*props\.files\.find\(file => file\.taskId === task\.id\)/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'restore backup select should display task number and file path while submitting backup file id',
|
||||
/v-for="option in restoreBackupOptions"[\s\S]*:key="option\.backupFileId"[\s\S]*:label="option\.taskNo"[\s\S]*:value="option\.backupFileId"[\s\S]*restore-backup-option[\s\S]*option\.taskNo[\s\S]*option\.filePath/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'restore submit action should stay in the rightmost desktop column',
|
||||
/\.restore-table-action\s*\{[\s\S]*grid-column:\s*3[\s\S]*justify-content:\s*flex-end/.test(taskPanelSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms task panel backup grid contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms task panel backup grid contract passed')
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||
const getStyleRules = selector => {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return [...taskPanelSource.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, 'g'))]
|
||||
.map(match => match[1])
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const backupTableSelectRule = getStyleRules('.backup-table-select')
|
||||
const backupTableActionRule = getStyleRules('.backup-table-action')
|
||||
const sizeSplitActionRule = getStyleRules('.backup-form-grid.is-size-split .backup-table-action')
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'backup panel should not render selected connection header',
|
||||
!/<div class="card-header">[\s\S]*当前连接/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup panel should embed task status card below the form',
|
||||
/<DbmsTaskStatusCard[\s\S]*:tasks="currentTasks"[\s\S]*:files="files"[\s\S]*@refresh-tasks="handleRefreshCurrentTasks"/.test(
|
||||
taskPanelSource
|
||||
) && /currentTasks[\s\S]*activeTab\.value === 'restore' \? props\.restoreTasks : props\.tasks/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup mode layout should use one three-column grid for dynamic fields',
|
||||
/<div class="backup-form-grid form-grid"[\s\S]*:class="backupModeLayoutClass"[\s\S]*<el-form-item[\s\S]*class="backup-table-select"[\s\S]*handleBackupSubmit[\s\S]*<\/div>/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'backup table select should keep one desktop form column',
|
||||
/grid-column:\s*span 1/.test(backupTableSelectRule)
|
||||
],
|
||||
[
|
||||
'backup submit action should stay in the rightmost desktop column by default',
|
||||
/grid-column:\s*3/.test(backupTableActionRule)
|
||||
],
|
||||
[
|
||||
'size split backup submit action should flow after file size and table select',
|
||||
/grid-column:\s*span 1/.test(sizeSplitActionRule)
|
||||
],
|
||||
[
|
||||
'backup submit action should align to the right of the table select row',
|
||||
/justify-content:\s*flex-end/.test(backupTableActionRule)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms task panel backup submit layout contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms task panel backup submit layout contract passed')
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'task panel should render connection summary in its own topbar instead of tabs extra slot',
|
||||
/class="task-panel-topbar"[\s\S]*class="task-panel-connection"/.test(taskPanelSource) &&
|
||||
!/#extra[\s\S]*class="task-panel-connection"/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'task panel connection summary should include database or schema name before host and port',
|
||||
/const connectionTitle = computed[\s\S]*databaseName[\s\S]*schemaName[\s\S]*connection\.host[\s\S]*connection\.port/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'task panel should keep connection info button opening current connection',
|
||||
/<el-button[\s\S]*@click="emit\('edit-connection', selectedConnection\)"[\s\S]*<\/el-button>/.test(taskPanelSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms task panel connection summary contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms task panel connection summary contract passed')
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
const files = {
|
||||
taskPanel: path.join(root, 'components/DbmsTaskPanel.vue'),
|
||||
payload: path.join(root, 'utils/taskPayload.ts')
|
||||
}
|
||||
|
||||
const read = file => fs.readFileSync(file, 'utf8')
|
||||
const taskPanelSource = read(files.taskPanel)
|
||||
const payloadSource = read(files.payload)
|
||||
|
||||
const checks = [
|
||||
['task panel should not render temporary password field', !/label="临时密码"/.test(taskPanelSource)],
|
||||
['backup form model should not keep temporaryPassword', !/DbmsBackupFormModel[\s\S]*temporaryPassword:\s*string/.test(payloadSource)],
|
||||
['restore form model should not keep temporaryPassword', !/DbmsRestoreFormModel[\s\S]*temporaryPassword:\s*string/.test(payloadSource)],
|
||||
['backup payload should not send temporaryPassword', !/buildBackupPayload[\s\S]*temporaryPassword:/.test(payloadSource)],
|
||||
['restore payload should not send temporaryPassword', !/buildRestorePayload[\s\S]*temporaryPassword:/.test(payloadSource)]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms task panel passwordless task contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms task panel passwordless task contract passed')
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'task panel card body should not add top-level padding',
|
||||
/\.card-body\s*\{[\s\S]*padding:\s*0/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup and restore operation tabs should not add extra top padding',
|
||||
!/\.operation-tabs\s*\{[\s\S]*padding-top:/.test(taskPanelSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms task panel tab spacing contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms task panel tab spacing contract passed')
|
||||
@@ -0,0 +1,30 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.join(currentDir, '..')
|
||||
const taskPanelSource = fs.readFileSync(path.join(pageDir, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||
|
||||
const checks = [
|
||||
['backup table select supports multiple values', /<el-select[\s\S]*v-model="backupForm\.targetNames"[\s\S]*\bmultiple\b/.test(taskPanelSource)],
|
||||
['backup table select keeps collapsed tags tooltip', /<el-select[\s\S]*v-model="backupForm\.targetNames"[\s\S]*\bcollapse-tags-tooltip\b/.test(taskPanelSource)],
|
||||
[
|
||||
'backup table select displays at most two selected table tags before collapsing',
|
||||
/<el-select[\s\S]*v-model="backupForm\.targetNames"[\s\S]*:max-collapse-tags="2"/.test(taskPanelSource)
|
||||
],
|
||||
[
|
||||
'backup table select dropdown should not force a two-column option grid',
|
||||
!/\.dbms-table-select-popper \.el-select-dropdown__list\s*\{[\s\S]*grid-template-columns:\s*repeat\(2/.test(taskPanelSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('dbms task panel table select contract failed:')
|
||||
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms task panel table select contract passed')
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const statusCardSource = fs.readFileSync(path.join(root, 'components/DbmsTaskStatusCard.vue'), 'utf8')
|
||||
|
||||
const taskTableStart = statusCardSource.indexOf('<el-table :data="tasks"')
|
||||
const taskTableEnd = statusCardSource.indexOf('</el-table>', taskTableStart)
|
||||
const taskTableSource = statusCardSource.slice(taskTableStart, taskTableEnd)
|
||||
const restoreStart = taskTableSource.indexOf('<template v-if="operationType === \'RESTORE\'">')
|
||||
const backupStart = taskTableSource.indexOf('<template v-else>', restoreStart)
|
||||
const restoreTableSource = taskTableSource.slice(restoreStart, backupStart)
|
||||
const backupTableSource = taskTableSource.slice(backupStart)
|
||||
|
||||
const backupOrderedMarkers = [
|
||||
'prop="taskNo"',
|
||||
'prop="schemaName"',
|
||||
'parseJsonArrayText(row.targetNamesJson)',
|
||||
'prop="progressPercent"',
|
||||
'sort-by="taskStatus"',
|
||||
'resolveTaskFilePath(row)',
|
||||
'formatDateTime(row.startedAt)',
|
||||
'formatDateTime(row.finishedAt)',
|
||||
'prop="resultMessage"',
|
||||
'width="230"'
|
||||
]
|
||||
const markerPositions = backupOrderedMarkers.map(marker => [marker, backupTableSource.indexOf(marker)])
|
||||
|
||||
const checks = [
|
||||
['task record table should not render type column', !/label="[^"]*type[^"]*"/i.test(taskTableSource)],
|
||||
['task status card should not render backup file tab', !/localFileQuery|emitFileQuery|refresh-files|file-page-change/.test(statusCardSource)],
|
||||
['backup task record table should include database column', /prop="schemaName"/.test(backupTableSource)],
|
||||
[
|
||||
'backup task record table should follow required column order',
|
||||
markerPositions.every(([, index]) => index >= 0) &&
|
||||
markerPositions.every(([, index], currentIndex) => currentIndex === 0 || index > markerPositions[currentIndex - 1][1])
|
||||
],
|
||||
['restore task record table should use restore mode formatter', /formatRestoreMode\(row\.restoreMode\)/.test(restoreTableSource)],
|
||||
['task record path should default to dash', /resolveTaskFilePath\(row\) \|\| '-'/.test(statusCardSource)],
|
||||
['task record path should open backup file dialog', /openTaskBackupFileDialog\(row\)/.test(statusCardSource)],
|
||||
['backup file dialog should use readonly form layout', /backup-file-form/.test(statusCardSource)],
|
||||
['backup file dialog should not use table layout', !/<el-dialog[\s\S]*<el-table/.test(statusCardSource)],
|
||||
['backup file dialog should render readonly values', /backup-file-value/.test(statusCardSource)],
|
||||
['backup file dialog should keep footer slot', /#footer/.test(statusCardSource)],
|
||||
['task dates should use normalized datetime formatter', /formatDateTime\(row\.startedAt\)/.test(statusCardSource)],
|
||||
['task dates should use normalized datetime formatter for finish time', /formatDateTime\(row\.finishedAt\)/.test(statusCardSource)],
|
||||
['running tasks should expose start action', /canRestartTask\(row\)[\s\S]*emit\('restart-task', row\)/.test(statusCardSource)],
|
||||
['successful tasks should disable start action instead of hiding it', /:disabled="!canRestartTask\(row\)"/.test(statusCardSource)],
|
||||
['non-running tasks should disable stop action instead of hiding it', /:disabled="!canStopTask\(row\)"/.test(statusCardSource)],
|
||||
['task actions should not hide stop action', !/v-if="canStopTask\(row\)"/.test(statusCardSource)],
|
||||
['task actions should not hide start action', !/v-if="canRestartTask\(row\)"/.test(statusCardSource)],
|
||||
[
|
||||
'task action column should stay wide for backup and shrink for restore',
|
||||
/width="150" fixed="right"/.test(restoreTableSource) && /width="230" fixed="right"/.test(backupTableSource)
|
||||
],
|
||||
['task action buttons should stay on one line', /class="task-actions"/.test(statusCardSource) && /white-space:\s*nowrap/.test(statusCardSource)]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms task status card contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms task status card contract passed')
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.join(currentDir, '..')
|
||||
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||
|
||||
const pageSource = read('index.vue')
|
||||
const toolbarSource = read('components/DbmsToolbar.vue')
|
||||
|
||||
const checks = [
|
||||
['page stores connected connection id separately', /const connectedConnectionId = ref\(''\)/.test(pageSource)],
|
||||
[
|
||||
'page passes selected connected state to toolbar',
|
||||
/<DbmsToolbar[\s\S]*:has-connected-connection="isSelectedConnectionConnected"/.test(pageSource)
|
||||
],
|
||||
[
|
||||
'stored connection test marks connection connected only after success',
|
||||
/if\s*\(result\.data\.success && payload\.connectionId\)[\s\S]*connectedConnectionId\.value = payload\.connectionId/.test(pageSource)
|
||||
],
|
||||
[
|
||||
'successful table loading also marks the selected database connected',
|
||||
/tables\.value = result\.data \|\| \[\][\s\S]*connectedConnectionId\.value = selectedConnection\.value\.id/.test(pageSource)
|
||||
],
|
||||
[
|
||||
'toolbar disables query table view and backup until connected',
|
||||
/requiresConnection:\s*true[\s\S]*command:\s*'newQuery'[\s\S]*requiresConnection:\s*true[\s\S]*command:\s*'tables'[\s\S]*requiresConnection:\s*true[\s\S]*command:\s*'views'[\s\S]*requiresConnection:\s*true[\s\S]*command:\s*'backup'/.test(
|
||||
toolbarSource
|
||||
)
|
||||
],
|
||||
['toolbar derives item disabled state from connected prop', /item\.requiresConnection && !props\.hasConnectedConnection/.test(toolbarSource)]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('dbms toolbar connected state contract failed:')
|
||||
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms toolbar connected state contract passed')
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pageDir = path.join(currentDir, '..')
|
||||
const read = file => fs.readFileSync(path.join(pageDir, file), 'utf8')
|
||||
|
||||
const pageSource = read('index.vue')
|
||||
const treeSource = read('components/DbmsConnectionTree.vue')
|
||||
|
||||
const checks = [
|
||||
['tree node binds double click connect event', /@dblclick\.stop="handleNodeDoubleClick\(data\)"/.test(treeSource)],
|
||||
['tree exposes connect emit for stored connection', /connect:\s*\[connection:\s*Dbms\.ConnectionRecord\]/.test(treeSource)],
|
||||
['tree exposes open tables emit for schema double click', /openTables:\s*\[connection:\s*Dbms\.ConnectionRecord\]/.test(treeSource)],
|
||||
['schema double click opens database table list', /if\s*\(node\.type === 'schema'\)[\s\S]*emit\('openTables',\s*node\.connection\)/.test(treeSource)],
|
||||
['page listens tree connect event', /<DbmsConnectionTree[\s\S]*@connect="handleTestStoredConnection"/.test(pageSource)],
|
||||
['page listens tree open tables event', /<DbmsConnectionTree[\s\S]*@open-tables="handleOpenConnectionTables"/.test(pageSource)],
|
||||
['page reuses stored connection test handler', /const handleTestStoredConnection = async \(row: Dbms\.ConnectionRecord\) => \{[\s\S]*connectionId:\s*row\.id/.test(pageSource)],
|
||||
[
|
||||
'table list loading should silence missing backend route status toast',
|
||||
/getDbmsTableList\(\{[\s\S]*schemaName:[\s\S]*\},\s*\{\s*silentStatusError:\s*true\s*\}\)/.test(pageSource)
|
||||
],
|
||||
[
|
||||
'table list loading should fall back when backend route is missing',
|
||||
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*tables\.value\s*=\s*\[\][\s\S]*ElMessage\.warning/.test(pageSource)
|
||||
],
|
||||
[
|
||||
'tree selection background task loads should silence missing backend route status toast',
|
||||
/getDbmsBackupTaskList\(/.test(pageSource) &&
|
||||
/getDbmsRestoreTaskList\(/.test(pageSource) &&
|
||||
/getDbmsBackupFileList\(/.test(pageSource) &&
|
||||
(pageSource.match(/silentStatusError:\s*true/g) || []).length >= 5
|
||||
],
|
||||
[
|
||||
'tree selection background task loads should fall back when backend routes are missing',
|
||||
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*tasks\.value\s*=\s*\[\][\s\S]*taskTotal\.value\s*=\s*0/.test(
|
||||
pageSource
|
||||
) &&
|
||||
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*restoreTasks\.value\s*=\s*\[\][\s\S]*restoreTaskTotal\.value\s*=\s*0/.test(
|
||||
pageSource
|
||||
) &&
|
||||
/if\s*\(!isNotFoundError\(error\)\)\s*throw error[\s\S]*files\.value\s*=\s*\[\][\s\S]*fileTotal\.value\s*=\s*0/.test(
|
||||
pageSource
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
const failures = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('dbms tree double click connect contract failed:')
|
||||
failures.forEach(([message]) => console.error(`- ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms tree double click connect contract passed')
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..')
|
||||
const workspaceSource = fs.readFileSync(path.join(root, 'components/DbmsWorkspace.vue'), 'utf8')
|
||||
const taskPanelSource = fs.readFileSync(path.join(root, 'components/DbmsTaskPanel.vue'), 'utf8')
|
||||
|
||||
const checks = [
|
||||
[
|
||||
'workspace title should render connection name with host and port suffix',
|
||||
/<span>\{\{ title \}\}<\/span>[\s\S]*class="workspace-title-suffix"[\s\S]*selectedConnection\.host[\s\S]*selectedConnection\.port/.test(
|
||||
workspaceSource
|
||||
) && /return selectedConnection\.connectionName/.test(workspaceSource)
|
||||
],
|
||||
[
|
||||
'workspace header should not render host and port as a separate small subtitle',
|
||||
!/<small\s+v-if="selectedConnection">[\s\S]*selectedConnection\.host[\s\S]*selectedConnection\.port[\s\S]*<\/small>/.test(
|
||||
workspaceSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'workspace title host and port suffix should use normal font weight',
|
||||
/class="workspace-title-suffix"[\s\S]*selectedConnection\.host[\s\S]*selectedConnection\.port/.test(workspaceSource) &&
|
||||
/\.workspace-title-suffix\s*\{[\s\S]*font-weight:\s*400/.test(workspaceSource)
|
||||
],
|
||||
[
|
||||
'backup workspace should not render the outer workspace header',
|
||||
/v-if="activeSection !== 'backup'"[\s\S]*class="workspace-header"/.test(workspaceSource)
|
||||
],
|
||||
[
|
||||
'backup task panel should render connection title in topbar area',
|
||||
/class="task-panel-topbar"[\s\S]*task-panel-connection[\s\S]*\{\{ connectionTitle \}\}/.test(taskPanelSource) &&
|
||||
/const connectionTitle = computed[\s\S]*connection\.connectionName[\s\S]*connection\.host[\s\S]*connection\.port/.test(
|
||||
taskPanelSource
|
||||
)
|
||||
],
|
||||
[
|
||||
'backup task panel should expose connection info action',
|
||||
/@click="emit\('edit-connection', selectedConnection\)"/.test(taskPanelSource)
|
||||
]
|
||||
]
|
||||
|
||||
const failed = checks.filter(([, passed]) => !passed)
|
||||
|
||||
if (failed.length) {
|
||||
console.error('dbms workspace title contract failed:')
|
||||
for (const [message] of failed) {
|
||||
console.error(`- ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('dbms workspace title contract passed')
|
||||
Reference in New Issue
Block a user