diff --git a/console-ui/e2e/status-bar.spec.js b/console-ui/e2e/status-bar.spec.js new file mode 100644 index 0000000..70223b9 --- /dev/null +++ b/console-ui/e2e/status-bar.spec.js @@ -0,0 +1,127 @@ +import { expect, test } from '@playwright/test' + +const VIEWPORTS = [ + { width: 320, height: 568 }, + { width: 360, height: 800 }, + { width: 390, height: 844 }, + { width: 768, height: 1024 }, + { width: 1024, height: 768 }, + { width: 1440, height: 900 }, +] + +const API_RESPONSES = { + '/api/v1/device': { + deviceName: 'edge-link-01', + hostname: 'edge-link-01', + platform: 'linux', + cpuModel: 'Test CPU', + uptimeHuman: '1 小时', + agentVersion: 'test', + }, + '/api/v1/metrics': { + cpuUsedPercent: 32, + memoryUsed: 4 * 1024 ** 3, + memoryTotal: 16 * 1024 ** 3, + memoryUsedPercent: 25, + gpuData: [], + diskData: [], + }, + '/api/v1/metrics/history': {}, + '/api/v1/apps': [], + '/api/v1/alerts': [], +} + +async function openConsole(page, viewport) { + await page.setViewportSize(viewport) + await page.addInitScript(() => { + localStorage.setItem('edge_token', 'status-bar-test-token') + localStorage.setItem('edgex-user-prefs', JSON.stringify({ topbar: 'dark', online: true })) + }) + await page.route('**/api/v1/**', async (route) => { + const pathname = new URL(route.request().url()).pathname + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(API_RESPONSES[pathname] ?? []), + }) + }) + await page.goto('/') + await expect(page.locator('.edge-status-bar')).toBeVisible() +} + +for (const viewport of VIEWPORTS) { + test(`${viewport.width}px Cloud Link 可见且状态栏无横向溢出`, async ({ page }) => { + const errors = [] + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + page.on('pageerror', (error) => errors.push(error.message)) + + await openConsole(page, viewport) + + const link = page.locator('.edge-cloud-link') + const led = link.locator('.edge-cloud-link-led') + const label = link.locator('.edge-cloud-link-label') + await expect(link).toHaveAttribute('aria-label', '云端连接状态:云端在线') + await expect(link).toHaveAttribute('title', '云端连接状态:云端在线') + await expect(link).toHaveAttribute('data-cloud-link-state', 'online') + await expect(led).toBeVisible() + await expect(led).toHaveCSS('background-color', 'rgb(34, 197, 94)') + await expect(led).toHaveCSS('width', '8px') + await expect(led).toHaveCSS('height', '8px') + await expect(link).toHaveCSS('min-width', '44px') + await expect(link).toHaveCSS('min-height', '44px') + + if (viewport.width < 1024) { + await expect(label).toBeHidden() + } else { + await expect(label).toBeVisible() + await expect(label).toHaveText('云端在线') + } + + const overflow = await page.evaluate(() => { + const statusBar = document.querySelector('.edge-status-bar') + return { + documentClientWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + statusClientWidth: statusBar.clientWidth, + statusScrollWidth: statusBar.scrollWidth, + } + }) + expect(overflow.documentScrollWidth).toBe(overflow.documentClientWidth) + expect(overflow.statusScrollWidth).toBe(overflow.statusClientWidth) + expect(errors).toEqual([]) + }) +} + +test('Cloud Link 四态颜色与连接中动画语义', async ({ page }) => { + await openConsole(page, { width: 1440, height: 900 }) + const link = page.locator('.edge-cloud-link') + const led = link.locator('.edge-cloud-link-led') + const states = [ + ['online', 'rgb(34, 197, 94)', false], + ['connecting', 'rgb(245, 158, 11)', true], + ['offline', 'rgb(220, 107, 103)', false], + ['unknown', 'rgb(148, 163, 184)', false], + ] + + for (const [state, color, animated] of states) { + await link.evaluate((element, nextState) => { + element.className = `edge-cloud-link edge-cloud-link--${nextState}` + element.dataset.cloudLinkState = nextState + }, state) + await expect(led).toHaveCSS('background-color', color) + const animationName = await led.evaluate((element) => getComputedStyle(element).animationName) + expect(animationName === 'edgeCloudLinkConnecting').toBe(animated) + } +}) + +test('连接中动画尊重 prefers-reduced-motion', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await openConsole(page, { width: 1440, height: 900 }) + const link = page.locator('.edge-cloud-link') + await link.evaluate((element) => { + element.className = 'edge-cloud-link edge-cloud-link--connecting' + }) + await expect(link.locator('.edge-cloud-link-led')).toHaveCSS('animation-name', 'none') +}) diff --git a/console-ui/index.html b/console-ui/index.html index 0fabfb1..f0f1515 100644 --- a/console-ui/index.html +++ b/console-ui/index.html @@ -2,6 +2,7 @@ + Edge-X · 本地控制台 diff --git a/console-ui/package-lock.json b/console-ui/package-lock.json index 3aec1bc..283d7fc 100644 --- a/console-ui/package-lock.json +++ b/console-ui/package-lock.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.55.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -642,6 +643,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", + "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", @@ -3375,6 +3392,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", diff --git a/console-ui/package.json b/console-ui/package.json index 144d8b1..3981cef 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:status-bar": "playwright test e2e/status-bar.spec.js" }, "dependencies": { "@xterm/addon-fit": "0.10.0", @@ -20,6 +21,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.55.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/console-ui/playwright.config.js b/console-ui/playwright.config.js new file mode 100644 index 0000000..38aa45b --- /dev/null +++ b/console-ui/playwright.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + expect: { timeout: 5_000 }, + use: { + baseURL: 'http://127.0.0.1:4173', + browserName: 'chromium', + headless: true, + }, + webServer: { + command: 'npm run preview -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: false, + timeout: 30_000, + }, +}) diff --git a/console-ui/src/components/AppShell.jsx b/console-ui/src/components/AppShell.jsx index a632824..f86cc88 100644 --- a/console-ui/src/components/AppShell.jsx +++ b/console-ui/src/components/AppShell.jsx @@ -13,6 +13,7 @@ import DiskManager from '../pages/DiskManager' import NetworkConnections from '../pages/NetworkConnections' import MonitoringApp from '../pages/Monitoring' import AIActivity from '../pages/AIActivity' +import { useViewportEnvironment } from '../hooks/useViewportEnvironment' // Reusable face header function FaceHeader({ accent = T.blue, title, subtitle, version, kb, onMgmt, extra, errorMode }) { @@ -46,8 +47,10 @@ function FaceHeader({ accent = T.blue, title, subtitle, version, kb, onMgmt, ext // IframeFace — generic iframe wrapper for installed apps // ═══════════════════════════════════════════════════════════════ function IframeFace({ app, onMgmt }) { - // Resolve the first HostPort from the app's port mappings - const hostPort = app?.ports?.find(p => p.hostPort > 0)?.hostPort + const { compactWindow } = useViewportEnvironment(); + // Browser-reachable mappings expose nodePort; containerPort is only a last-resort legacy fallback. + const hostPort = app?.ports?.find(p => p.nodePort > 0)?.nodePort + || app?.ports?.find(p => p.hostPort > 0)?.hostPort || app?.ports?.find(p => p.containerPort > 0)?.containerPort; if (!hostPort) { @@ -66,10 +69,19 @@ function IframeFace({ app, onMgmt }) { const src = `http://${window.location.hostname}:${hostPort}`; return ( -
+
+ errorMode={app?.state === 'error'} + extra={ + 新窗口 + }/> + {compactWindow && ( +
+ + 移动设备上的嵌入体验取决于目标应用;如遇到空白、登录或触控问题,请使用始终可用的“新窗口”。 +
+ )}