Compare commits
2 Commits
8e0c37bf74
...
6f19890a84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f19890a84 | ||
|
|
1ee33672dd |
14
package-lock.json
generated
14
package-lock.json
generated
@@ -41,6 +41,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"dotenv": "^17.3.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -5591,6 +5592,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv-expand": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"dotenv": "^17.3.1",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
@@ -74,9 +75,9 @@
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.18",
|
||||
"tsx": "^4.19.3"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { chromium, type BrowserContext, type Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import type { ErpConfig, ErpSession } from '../../types/erp.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const log = createLogger('ErpAuthService')
|
||||
|
||||
// Timeout constants
|
||||
const PAGE_LOAD_TIMEOUT = 10000
|
||||
const LOGIN_RESULT_TIMEOUT = 15000
|
||||
const FORCE_LOGIN_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* ERP Authentication Service
|
||||
* Manages login session and browser lifecycle
|
||||
@@ -51,10 +56,13 @@ export class ErpAuthService {
|
||||
await page.goto(loginUrl)
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 10000 })
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT })
|
||||
|
||||
// Wait for iframe to be present
|
||||
await page.waitForSelector('#forwardFrame', { state: 'attached', timeout: 15000 })
|
||||
await page.waitForSelector('#forwardFrame', {
|
||||
state: 'attached',
|
||||
timeout: LOGIN_RESULT_TIMEOUT
|
||||
})
|
||||
|
||||
// Extract forwardFrame (Python: main_frame = page.locator("#forwardFrame").content_frame)
|
||||
// This is the main working frame for all subsequent operations
|
||||
@@ -89,29 +97,11 @@ export class ErpAuthService {
|
||||
throw new Error(`Failed to click login button: ${e}`)
|
||||
}
|
||||
|
||||
// Wait for navigation after login
|
||||
// The login will redirect to the main page which has a different structure
|
||||
try {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 10000 })
|
||||
} catch (e) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: PAGE_LOAD_TIMEOUT }).catch(() => {
|
||||
log.warn('Page load state check timed out, continuing')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle force login confirmation dialog if present (Python: get_by_role("button", name="确定"))
|
||||
try {
|
||||
const confirmBtn = mainFrame.getByRole('button', { name: '确定' })
|
||||
const count = await confirmBtn.count()
|
||||
if (count > 0) {
|
||||
log.info('Force login detected, clicking confirm button')
|
||||
await confirmBtn.first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
} else {
|
||||
log.debug('Normal login, no confirmation dialog')
|
||||
}
|
||||
} catch {
|
||||
// No force login dialog, continue
|
||||
log.debug('Normal login, no confirmation dialog')
|
||||
}
|
||||
await this.waitForLoginResult(mainFrame as unknown as import('playwright').Frame)
|
||||
|
||||
// Create session with mainFrame (Python returns main_frame as part of login result)
|
||||
this.session = {
|
||||
@@ -126,6 +116,47 @@ export class ErpAuthService {
|
||||
return this.session
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for login result: success, failure, or force login confirmation
|
||||
*/
|
||||
private async waitForLoginResult(mainFrame: import('playwright').Frame): Promise<void> {
|
||||
const successLocator = mainFrame.locator('.nc-workbench-icon')
|
||||
const errorLocator = mainFrame.getByText('名称或密码错误')
|
||||
const forceLoginButton = mainFrame.getByRole('button', { name: '确定' })
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
successLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
|
||||
errorLocator.waitFor({ state: 'visible', timeout: LOGIN_RESULT_TIMEOUT }),
|
||||
forceLoginButton
|
||||
.waitFor({ state: 'visible', timeout: FORCE_LOGIN_TIMEOUT })
|
||||
.then(async () => {
|
||||
log.info('Force login dialog detected, clicking confirm')
|
||||
await forceLoginButton.click()
|
||||
await this.waitForLoginResult(mainFrame)
|
||||
})
|
||||
])
|
||||
|
||||
const hasError = await errorLocator.isVisible()
|
||||
if (hasError) {
|
||||
throw new Error('ERP 登录失败:名称或密码错误')
|
||||
}
|
||||
|
||||
log.info('Login successful')
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('名称或密码错误')) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const hasError = await errorLocator.isVisible().catch(() => false)
|
||||
if (hasError) {
|
||||
throw new Error('ERP 登录失败:名称或密码错误')
|
||||
}
|
||||
|
||||
log.info('Login successful')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close browser and cleanup session
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { beforeAll, afterAll } from 'vitest'
|
||||
import { beforeAll, afterAll, vi } from 'vitest'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
|
||||
// Mock electron app module for unit tests
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
isReady: vi.fn().mockReturnValue(false),
|
||||
getPath: vi.fn().mockReturnValue(path.join(process.cwd(), 'logs')),
|
||||
on: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Load environment variables from project root
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') })
|
||||
|
||||
|
||||
@@ -56,4 +56,38 @@ describe('ERP Authentication Service (Unit)', () => {
|
||||
await expect(service.close()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Class Structure', () => {
|
||||
let service: ErpAuthService
|
||||
|
||||
beforeEach(() => {
|
||||
const config: ErpConfig = {
|
||||
url: 'https://test.example.com',
|
||||
username: 'testuser',
|
||||
password: 'testpass'
|
||||
}
|
||||
service = new ErpAuthService(config)
|
||||
})
|
||||
|
||||
it('should have login method that returns a Promise', () => {
|
||||
expect(service.login).toBeDefined()
|
||||
expect(typeof service.login).toBe('function')
|
||||
expect(service.login()).toBeInstanceOf(Promise)
|
||||
})
|
||||
|
||||
it('should have close method', () => {
|
||||
expect(service.close).toBeDefined()
|
||||
expect(typeof service.close).toBe('function')
|
||||
})
|
||||
|
||||
it('should have getSession method', () => {
|
||||
expect(service.getSession).toBeDefined()
|
||||
expect(typeof service.getSession).toBe('function')
|
||||
})
|
||||
|
||||
it('should have isActive method', () => {
|
||||
expect(service.isActive).toBeDefined()
|
||||
expect(typeof service.isActive).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user