diff --git a/console-ui/.gitignore b/console-ui/.gitignore index c2658d7..f0a232b 100644 --- a/console-ui/.gitignore +++ b/console-ui/.gitignore @@ -1 +1,4 @@ node_modules/ +dist/ +test-results/ +playwright-report/ diff --git a/console-ui/e2e/shortcuts.spec.js b/console-ui/e2e/shortcuts.spec.js new file mode 100644 index 0000000..5c36f6f --- /dev/null +++ b/console-ui/e2e/shortcuts.spec.js @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test' + +const device = { + deviceName: 'e2e-node', hostname: 'e2e-node', platform: 'linux', cpuModel: 'fixture', uptimeHuman: '1h', agentVersion: 'test', +} + +async function launchConsole(page) { + await page.addInitScript(() => localStorage.setItem('edge_token', 'e2e-token')) + await page.route('**/api/v1/**', async route => { + const url = new URL(route.request().url()) + let body = [] + if (url.pathname.endsWith('/device')) body = device + if (url.pathname.endsWith('/metrics')) body = { + cpuUsedPercent: 12, + memoryUsed: 2 * 1024 ** 3, + memoryTotal: 8 * 1024 ** 3, + memoryUsedPercent: 25, + gpuData: [], + diskData: [], + } + if (url.pathname.endsWith('/metrics/history')) body = {} + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }) + }) + await page.goto('/') +} + +test('opens keyboard help from shortcut and status bar', async ({ page }) => { + await launchConsole(page) + await page.keyboard.press('Control+/') + await expect(page.getByRole('dialog', { name: '键盘快捷键' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: '键盘快捷键' })).toBeHidden() + await page.getByRole('button', { name: '打开键盘快捷键帮助' }).click() + await expect(page.getByText('跨域 iframe')).toBeVisible() +}) + +test('window shortcuts operate current apps and ignore input', async ({ page }) => { + await launchConsole(page) + await page.getByText('仪表盘', { exact: true }).dblclick() + await expect(page.getByText('仪表盘 · 总览', { exact: true })).toBeVisible() + + await page.keyboard.press('Control+Alt+m') + await expect(page.getByText('仪表盘 · 总览', { exact: true })).toBeHidden() + await page.keyboard.press('Alt+1') + await expect(page.getByText('仪表盘 · 总览', { exact: true })).toBeVisible() + await page.keyboard.press('Control+Alt+f') + await page.keyboard.press('Control+Alt+d') + await expect(page.getByText('仪表盘 · 总览', { exact: true })).toBeHidden() + await page.keyboard.press('Alt+1') + await page.keyboard.press('Control+Alt+w') + await expect(page.getByText('仪表盘 · 总览', { exact: true })).toBeHidden() + + await page.getByText('操作日志', { exact: true }).dblclick() + const input = page.getByPlaceholder('用户名(模糊)') + await input.focus() + await page.keyboard.press('Control+Alt+w') + await expect(input).toBeVisible() +}) + +test('ordinary app content keeps shortcuts active while terminal scope suppresses them', async ({ page }) => { + await launchConsole(page) + await page.getByText('文件', { exact: true }).dblclick() + const ordinaryApp = page.getByText('工作区', { exact: true }).first() + await ordinaryApp.click() + await page.keyboard.press('Control+Alt+m') + await expect(ordinaryApp).toBeHidden() + await page.evaluate(() => { + const scope = document.createElement('div') + scope.dataset.shortcutScope = 'terminal' + scope.tabIndex = 0 + scope.textContent = 'terminal shortcut scope fixture' + document.body.append(scope) + scope.focus() + }) + await page.keyboard.press('Alt+1') + await expect(ordinaryApp).toBeHidden() +}) diff --git a/console-ui/eslint.config.js b/console-ui/eslint.config.js index ea36dd3..01502a7 100644 --- a/console-ui/eslint.config.js +++ b/console-ui/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'public/vendor']), { files: ['**/*.{js,jsx}'], extends: [ @@ -18,4 +18,14 @@ export default defineConfig([ parserOptions: { ecmaFeatures: { jsx: true } }, }, }, + { + files: ['src/overlays/OverlayProvider.jsx'], + rules: { 'react-refresh/only-export-components': 'off' }, + }, + { + files: ['**/*.{test,spec}.{js,jsx}', 'src/test/**/*.{js,jsx}', 'e2e/**/*.{js,jsx}', 'playwright.config.js'], + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + }, ]) diff --git a/console-ui/package-lock.json b/console-ui/package-lock.json index 3aec1bc..7500a79 100644 --- a/console-ui/package-lock.json +++ b/console-ui/package-lock.json @@ -18,6 +18,10 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.0", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -26,9 +30,69 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", - "vite": "^8.0.12" + "jsdom": "^29.1.1", + "vite": "^8.0.12", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -221,6 +285,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -269,6 +343,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@code-inspector/core": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/@code-inspector/core/-/core-1.5.3.tgz", @@ -335,6 +422,146 @@ "@code-inspector/core": "1.5.3" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -497,6 +724,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -642,6 +887,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "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", @@ -906,6 +1167,106 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -917,6 +1278,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -984,6 +1371,129 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.35", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", @@ -1136,6 +1646,16 @@ "readable-stream": "^2.0.6" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1143,6 +1663,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1193,6 +1723,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -1413,6 +1953,16 @@ "vite": ">=5.0.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", @@ -1555,6 +2105,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1562,6 +2133,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1580,6 +2165,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", @@ -1624,6 +2216,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -1645,6 +2247,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1739,6 +2349,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1990,6 +2607,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2428,6 +3055,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2502,6 +3142,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2559,6 +3209,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2579,6 +3236,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2948,6 +3656,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2958,6 +3687,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -3036,6 +3772,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -3240,10 +3986,24 @@ "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" } }, "node_modules/on-finished": { @@ -3318,6 +4078,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3355,6 +4141,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3375,6 +4168,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "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", @@ -3468,6 +4308,47 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3586,6 +4467,14 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -3607,6 +4496,30 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -3668,6 +4581,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3854,6 +4780,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -3901,6 +4834,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3911,6 +4851,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -3952,6 +4899,19 @@ "node": ">=0.10.0" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -3974,6 +4934,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -4016,6 +4983,23 @@ "node": ">= 6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4033,6 +5017,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4043,6 +5057,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4088,6 +5128,16 @@ "node": ">= 0.6" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4243,6 +5293,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4259,6 +5447,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -4284,6 +5489,23 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/console-ui/package.json b/console-ui/package.json index 144d8b1..42573c1 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -7,6 +7,10 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "vitest run", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", "preview": "vite preview" }, "dependencies": { @@ -20,6 +24,10 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.0", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", @@ -28,6 +36,8 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", - "vite": "^8.0.12" + "jsdom": "^29.1.1", + "vite": "^8.0.12", + "vitest": "^4.1.10" } } diff --git a/console-ui/playwright.config.js b/console-ui/playwright.config.js new file mode 100644 index 0000000..1dad124 --- /dev/null +++ b/console-ui/playwright.config.js @@ -0,0 +1,21 @@ +import { existsSync } from 'node:fs' +import { defineConfig } from '@playwright/test' + +const cachedChromium = '/home/kasm-user/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' +const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE + || (existsSync(cachedChromium) ? cachedChromium : undefined) + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + launchOptions: executablePath ? { executablePath } : undefined, + }, + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/console-ui/src/App.jsx b/console-ui/src/App.jsx index eadca0a..ed37cb2 100644 --- a/console-ui/src/App.jsx +++ b/console-ui/src/App.jsx @@ -57,6 +57,10 @@ import { TweaksPanel, useTweaks, TweakSection, TweakRadio, TweakToggle, TweakCol import { useMetrics, useMetricsHistory, useApps, useAlerts, useDevice, setAuthToken, getAuthToken, clearAuth, setOnAuthExpired } from './hooks/useApi' import { SYSTEM_APPS } from './data/systemApps' import { AnimatePresence } from './motion' +import { ShortcutHelpDialog } from './components/ShortcutHelpDialog' +import { shortcutRegistry } from './shortcuts/shortcuts' +import { useGlobalShortcuts } from './shortcuts/useGlobalShortcuts' +import { useOverlayStack } from './overlays/OverlayProvider' const TWEAK_DEFAULTS = { "preset": "platform-dark", @@ -111,6 +115,7 @@ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } export default function App() { const [loggedIn, setLoggedIn] = useState(() => !!getAuthToken()); const [loginUser, setLoginUser] = useState(''); + const overlayStack = useOverlayStack(); const handleLogin = (token, username) => { setAuthToken(token); @@ -182,6 +187,7 @@ export default function App() { const [authReason, setAuthReason] = useState(null); const [pendingAction, setPendingAction] = useState(null); const [uninstalled, setUninstalled] = useState(() => new Set()); + const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false); // Launch an app: add to running set + focus its window. // New apps default to full-screen; already-running apps keep their last mode. @@ -302,6 +308,29 @@ export default function App() { // device 必须在 LoginScreen early-return 前定义(line 290 引用它) const device = liveDevice || {}; + const shortcutDockApps = useMemo(() => openApps + .map(id => appById[id]) + .filter(Boolean), [openApps, appById]); + const appShortcutRegistry = shortcutHelpOpen + ? shortcutRegistry.filter(shortcut => shortcut.action === 'toggle-shortcut-help') + : shortcutRegistry; + useGlobalShortcuts({ + enabled: loggedIn && (!overlayStack.hasLayers || shortcutHelpOpen), + registry: appShortcutRegistry, + actions: { + 'toggle-shortcut-help': () => setShortcutHelpOpen(open => !open), + 'show-desktop': showDesktop, + 'minimize-window': minimizeWindow, + 'toggle-maximized': () => setMaximized(current => !current), + 'close-window': closeWindow, + 'focus-dock-app': (index) => { + const app = shortcutDockApps[index]; + if (app) focusApp(app.id); + }, + }, + context: { activeId, dockApps: shortcutDockApps }, + }); + if (!loggedIn) { return ( @@ -369,6 +398,7 @@ export default function App() { online={t.online} lastSync="5 分钟前" onOpenAlerts={() => launchApp({ id: 'alerts' })} + onOpenShortcutHelp={() => setShortcutHelpOpen(true)} loginUser={loginUser} onLogout={handleLogout} /> @@ -468,6 +498,13 @@ export default function App() { {/* [Story 4.2 Disabled] AuthModal "节点访问授权" 入口删除 (LF 报告冲突 2026-06-21) 受控开发界面下登录是唯一身份门槛,退出在右上头像下拉 */} + {shortcutHelpOpen && ( + setShortcutHelpOpen(false)} + context={{ activeId, dockApps }} + /> + )} + {/* Tweaks panel */} diff --git a/console-ui/src/components/AppMgmtDrawer.jsx b/console-ui/src/components/AppMgmtDrawer.jsx index aa66639..f554f0f 100644 --- a/console-ui/src/components/AppMgmtDrawer.jsx +++ b/console-ui/src/components/AppMgmtDrawer.jsx @@ -5,6 +5,7 @@ import { Icon } from '../icons' import { StatusDot, Chip, Sparkline } from '../components/ui' import { useAppLogs, useAppVersions, switchAppVersion, appOp, deleteApp, useAppDetail } from '../hooks/useApi' import { motion, project, springs, useMotionPref } from '../motion' +import { useOverlayLayer } from '../overlays/OverlayProvider' import { btnSecondary, btnPrimary, btnDanger } from './AppWindow' import TabBar from './TabBar' // Story 4.7:「容器 Shell」tab + 渲染分支在 merge commit 693efd5 中被吞,2026-06-22 恢复 @@ -860,15 +861,21 @@ function MgmtVersions({ app }) { function UninstallConfirm({ app, onCancel, onConfirm }) { const [typed, setTyped] = useState(''); const matches = typed === app.name; + const dialogRef = useRef(null); + const inputRef = useRef(null); + const { backdropProps, layerProps } = useOverlayLayer({ + id: 'uninstall-confirm', onDismiss: onCancel, layerRef: dialogRef, initialFocusRef: inputRef, modal: true, + }); return ( -
-
e.stopPropagation()} className="edge-fade-in" style={{ +
-
卸载应用
+
卸载应用
此操作不可撤销
{app.name} 确认:
- setTyped(e.target.value)} + setTyped(e.target.value)} autoFocus placeholder={app.name} style={{ @@ -1023,6 +1030,14 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut const x = useMotionValue(open ? 0 : 1000); const [drawerWidth, setDrawerWidth] = useState(980); const backdropOpacity = useTransform(x, [0, drawerWidth || 1], [0.18, 0]); + const closeRef = useRef(null); + const { backdropProps, layerProps } = useOverlayLayer({ + id: 'app-management', + onDismiss: onClose, + layerRef: drawerRef, + initialFocusRef: closeRef, + active: open, + }); const measureDrawer = useCallback(() => { const width = drawerRef.current?.getBoundingClientRect().width; @@ -1111,7 +1126,7 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut <> {/* Backdrop */}
-
{app.name}
+
{app.name}
{stateCfg.label} @@ -1199,7 +1219,7 @@ export default function AppMgmtDrawer({ app, open, onClose, authed, onRequireAut {app.gpu || 'CPU'}
- + +
+ {groups.map(group => ( +
+

{group.label}

+ {group.shortcuts.map(shortcut => ( +
+
+
{shortcut.name}{shortcut.unavailable ? '(当前不可用)' : ''}
+
{shortcut.description}
+
+
formatBinding(binding, platform)).join(' 或 ')}> + {shortcut.bindings.map((binding, index) => ( + {formatBinding(binding, platform)} + ))} +
+
+ ))} +
+ ))} +
+ 快捷键仅在焦点位于 devbox Console UI 时生效。跨域 iframe、终端、代码编辑器和输入区域会接收自己的键盘事件,父页面无法也不会拦截这些按键。 +
+
+ +
+ ) +} diff --git a/console-ui/src/components/ShortcutHelpDialog.module.css b/console-ui/src/components/ShortcutHelpDialog.module.css new file mode 100644 index 0000000..4a51e6d --- /dev/null +++ b/console-ui/src/components/ShortcutHelpDialog.module.css @@ -0,0 +1,88 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(15, 23, 42, 0.48); + backdrop-filter: blur(8px); +} + +.dialog { + width: min(680px, calc(100vw - 32px)); + max-height: min(720px, calc(100vh - 48px)); + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 18px; + background: #fff; + color: #0f172a; + box-shadow: 0 30px 80px -20px rgba(15, 23, 42, 0.55); +} + +.header { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 22px 24px 18px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc, #fff); +} + +.title { margin: 0; font-size: 19px; letter-spacing: -0.02em; } +.subtitle { margin: 5px 0 0; color: #64748b; font-size: 12.5px; } +.close { + margin-left: auto; + width: 32px; + height: 32px; + border: 0; + border-radius: 9px; + background: #f1f5f9; + color: #475569; + cursor: pointer; +} +.close:hover { background: #e2e8f0; color: #0f172a; } + +.body { max-height: calc(min(720px, 100vh - 48px) - 104px); overflow: auto; padding: 18px 24px 22px; } +.section + .section { margin-top: 20px; } +.sectionTitle { margin: 0 0 8px; color: #64748b; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; } +.row { + display: grid; + grid-template-columns: minmax(150px, 1fr) minmax(180px, auto); + gap: 16px; + align-items: center; + padding: 10px 0; + border-top: 1px solid #f1f5f9; +} +.name { font-size: 13px; font-weight: 650; } +.description { margin-top: 3px; color: #64748b; font-size: 11.5px; line-height: 1.45; } +.keys { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 5px; } +.key { + min-width: 28px; + padding: 4px 8px; + border: 1px solid #cbd5e1; + border-bottom-width: 2px; + border-radius: 7px; + background: #f8fafc; + color: #334155; + font: 600 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; + text-align: center; +} +.notice { + margin-top: 20px; + padding: 11px 13px; + border: 1px solid #bfdbfe; + border-radius: 9px; + background: #eff6ff; + color: #1e40af; + font-size: 11.5px; + line-height: 1.6; +} + +@media (prefers-reduced-motion: no-preference) { + .backdrop { animation: backdropIn .16s ease-out; } + .dialog { animation: dialogIn .2s cubic-bezier(.2,.8,.2,1); } +} +@keyframes backdropIn { from { opacity: 0; } } +@keyframes dialogIn { from { opacity: 0; transform: translateY(8px) scale(.98); } } diff --git a/console-ui/src/components/StatusBar.jsx b/console-ui/src/components/StatusBar.jsx index dadf041..229f494 100644 --- a/console-ui/src/components/StatusBar.jsx +++ b/console-ui/src/components/StatusBar.jsx @@ -4,7 +4,7 @@ import { Icon } from '../icons' import { StatusDot, useClock } from './ui' import { AnimatePresence, PopScale } from '../motion' -export function StatusBar({ cpu, gpu, mem, alertCount, online, lastSync, onOpenAlerts, theme = 'light', deviceLabel, DEVICE, loginUser, onLogout }) { +export function StatusBar({ cpu, gpu, mem, alertCount, online, lastSync, onOpenAlerts, onOpenShortcutHelp, theme = 'light', deviceLabel, DEVICE, loginUser, onLogout }) { const isDarkBar = theme === 'dark' const now = useClock(); const hh = String(now.getHours()).padStart(2, '0'); @@ -134,6 +134,12 @@ export function StatusBar({ cpu, gpu, mem, alertCount, online, lastSync, onOpenA {/* Right: cloud + time */}
+
+ ) diff --git a/console-ui/src/overlays/OverlayProvider.jsx b/console-ui/src/overlays/OverlayProvider.jsx new file mode 100644 index 0000000..1fba311 --- /dev/null +++ b/console-ui/src/overlays/OverlayProvider.jsx @@ -0,0 +1,181 @@ +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +const OverlayContext = createContext(null) + +let scrollLockCount = 0 +let scrollLockPrevious = '' + +function lockBodyScroll() { + if (scrollLockCount === 0) { + scrollLockPrevious = document.body.style.overflow + document.body.style.overflow = 'hidden' + } + scrollLockCount += 1 + return () => { + scrollLockCount = Math.max(0, scrollLockCount - 1) + if (scrollLockCount === 0) document.body.style.overflow = scrollLockPrevious + } +} + +const focusableSelector = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(',') + +export function OverlayProvider({ children }) { + const layers = useRef([]) + const [layerCount, setLayerCount] = useState(0) + + const register = useCallback((layer) => { + layers.current.push(layer) + setLayerCount(layers.current.length) + return () => { + layers.current = layers.current.filter(item => item.token !== layer.token) + setLayerCount(layers.current.length) + } + }, []) + + const isTop = useCallback((token) => layers.current.at(-1)?.token === token, []) + const hasLayers = layerCount > 0 + + useEffect(() => { + function onKeyDown(event) { + if (event.code !== 'Escape' && event.key !== 'Escape') return + const top = layers.current.at(-1) + if (!top?.dismissible || typeof top.onDismiss !== 'function') return + event.preventDefault() + event.stopPropagation() + top.onDismiss() + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, []) + + const value = useMemo(() => ({ register, isTop, hasLayers }), [register, isTop, hasLayers]) + return {children} +} + +export function useOverlayStack() { + const context = useContext(OverlayContext) + if (!context) throw new Error('useOverlayStack must be used inside OverlayProvider') + return context +} + +function setBackgroundInert(layerElement) { + const restores = [] + let current = layerElement + while (current?.parentElement) { + const parent = current.parentElement + let sibling = current.previousElementSibling + while (sibling) { + const element = sibling + const hadInert = element.hasAttribute('inert') + const previousAriaHidden = element.getAttribute('aria-hidden') + element.setAttribute('inert', '') + element.setAttribute('aria-hidden', 'true') + restores.push(() => { + if (!hadInert) element.removeAttribute('inert') + if (previousAriaHidden == null) element.removeAttribute('aria-hidden') + else element.setAttribute('aria-hidden', previousAriaHidden) + }) + sibling = element.previousElementSibling + } + if (parent === document.body) break + current = parent + } + return () => restores.reverse().forEach(restore => restore()) +} + +export function useOverlayLayer({ + id, + onDismiss, + layerRef, + initialFocusRef, + modal = false, + dismissible = true, + active = true, +}) { + const context = useContext(OverlayContext) + if (!context) throw new Error('useOverlayLayer must be used inside OverlayProvider') + const { register, isTop } = context + + const tokenRef = useRef(Symbol(id || 'overlay')) + const dismissRef = useRef(onDismiss) + const previousFocusRef = useRef(null) + + useLayoutEffect(() => { + dismissRef.current = onDismiss + }, [onDismiss]) + + useLayoutEffect(() => { + if (!active) return undefined + const token = tokenRef.current + const unregister = register({ + token, + dismissible, + onDismiss: () => dismissRef.current?.(), + }) + return () => { + unregister() + const previous = previousFocusRef.current + if (previous?.isConnected) previous.focus({ preventScroll: true }) + } + }, [active, dismissible, register]) + + useLayoutEffect(() => { + if (!active) return undefined + const layer = layerRef.current + if (!layer) return undefined + previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null + const focusTarget = initialFocusRef?.current + || layer.querySelector('[autofocus], [data-initial-focus]') + || layer.querySelector(focusableSelector) + || layer + focusTarget.focus({ preventScroll: true }) + + const restoreEnvironment = modal ? setBackgroundInert(layer) : () => {} + const restoreScroll = modal ? lockBodyScroll() : () => {} + + return () => { + restoreEnvironment() + restoreScroll() + } + }, [active, initialFocusRef, layerRef, modal]) + + const onKeyDown = useCallback((event) => { + if (event.code !== 'Tab' || !isTop(tokenRef.current)) return + const layer = layerRef.current + if (!layer) return + const focusable = [...layer.querySelectorAll(focusableSelector)] + .filter(element => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true') + if (focusable.length === 0) { + event.preventDefault() + layer.focus() + return + } + const first = focusable[0] + const last = focusable.at(-1) + if (event.shiftKey && (document.activeElement === first || !layer.contains(document.activeElement))) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && (document.activeElement === last || !layer.contains(document.activeElement))) { + event.preventDefault() + first.focus() + } + }, [isTop, layerRef]) + + const onBackdropClick = useCallback((event) => { + if (event.target !== event.currentTarget || !dismissible || !isTop(tokenRef.current)) return + onDismiss?.() + }, [dismissible, isTop, onDismiss]) + + return { + isTopLayer: () => isTop(tokenRef.current), + layerProps: { onKeyDown }, + backdropProps: { onClick: onBackdropClick }, + } +} diff --git a/console-ui/src/overlays/OverlayProvider.test.jsx b/console-ui/src/overlays/OverlayProvider.test.jsx new file mode 100644 index 0000000..2ac4021 --- /dev/null +++ b/console-ui/src/overlays/OverlayProvider.test.jsx @@ -0,0 +1,97 @@ +import { useRef, useState } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { OverlayProvider, useOverlayLayer } from './OverlayProvider' + +function Layer({ name, onDismiss, modal = true }) { + const ref = useRef(null) + const { backdropProps, layerProps } = useOverlayLayer({ + id: name, + onDismiss, + modal, + layerRef: ref, + initialFocusRef: ref, + }) + return ( +
+
+ + +
+
+ ) +} + +function Stack({ firstDismiss, secondDismiss }) { + return ( + +
+ + +
+ ) +} + +describe('overlay stack', () => { + it('dismisses only the last registered layer and consumes Escape', () => { + const firstDismiss = vi.fn() + const secondDismiss = vi.fn() + render() + + const event = new KeyboardEvent('keydown', { code: 'Escape', key: 'Escape', bubbles: true, cancelable: true }) + window.dispatchEvent(event) + + expect(secondDismiss).toHaveBeenCalledOnce() + expect(firstDismiss).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(true) + }) + + it('dismisses only when the top layer backdrop itself is clicked', async () => { + const user = userEvent.setup() + const firstDismiss = vi.fn() + const secondDismiss = vi.fn() + render() + + await user.click(screen.getByTestId('second')) + expect(secondDismiss).not.toHaveBeenCalled() + await user.click(screen.getByTestId('second-backdrop')) + expect(secondDismiss).toHaveBeenCalledOnce() + }) + + it('traps focus inside the top layer', () => { + render() + const [first, last] = screen.getAllByRole('button', { name: /second/, hidden: true }) + last.focus() + fireEvent.keyDown(last, { code: 'Tab', key: 'Tab' }) + expect(document.activeElement).toBe(first) + fireEvent.keyDown(first, { code: 'Tab', key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(last) + }) + + it('locks scrolling, hides background, and restores focus after close', async () => { + const user = userEvent.setup() + function Harness() { + const [open, setOpen] = useState(false) + return ( + +
+ {open && setOpen(false)}/>} +
+ ) + } + render() + const launcher = screen.getByRole('button', { name: 'launcher' }) + await user.click(launcher) + + expect(document.body.style.overflow).toBe('hidden') + expect(screen.getByTestId('background')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('background')).toHaveAttribute('inert') + + fireEvent.keyDown(window, { code: 'Escape', key: 'Escape' }) + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + expect(launcher).toHaveFocus() + expect(document.body.style.overflow).toBe('') + expect(screen.getByTestId('background')).not.toHaveAttribute('aria-hidden') + }) +}) diff --git a/console-ui/src/pages/AuditLog.jsx b/console-ui/src/pages/AuditLog.jsx index 8de24cb..86411ad 100644 --- a/console-ui/src/pages/AuditLog.jsx +++ b/console-ui/src/pages/AuditLog.jsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { T } from '../tokens' import { Icon } from '../icons' import { btnSecondary } from '../components/AppWindow' import { getAuthToken } from '../hooks/useApi' import { AnimatePresence, PopScale } from '../motion' +import { useOverlayLayer } from '../overlays/OverlayProvider' // Story 7.1 audit 需要 Bearer token。所有 fetch 调用走 authFetch 自动带 token。 function authFetch(url, opts = {}) { @@ -285,31 +286,32 @@ function EventRow({ ev, meta, onClick }) { // CR C7: 右侧详情抽屉(spec AC-AUDIT-PAGE-TABLE 要求) function DetailDrawer({ ev, meta, onClose }) { const tone = TONE_COLOR[meta.tone] || TONE_COLOR.slate - useEffect(() => { - function onKey(e) { if (e.key === 'Escape') onClose() } - document.addEventListener('keydown', onKey) - return () => document.removeEventListener('keydown', onKey) - }, [onClose]) + const drawerRef = useRef(null) + const closeRef = useRef(null) + const { backdropProps, layerProps } = useOverlayLayer({ + id: 'audit-detail', onDismiss: onClose, layerRef: drawerRef, initialFocusRef: closeRef, + }) return ( <> {/* 半透明蒙层(点击关闭) */} -
{/* 右侧抽屉 */} -
{/* header */}
- {meta.label}
事件 #{ev.id}
- diff --git a/console-ui/src/shortcuts/shortcuts.js b/console-ui/src/shortcuts/shortcuts.js new file mode 100644 index 0000000..8c172f0 --- /dev/null +++ b/console-ui/src/shortcuts/shortcuts.js @@ -0,0 +1,132 @@ +const modAlt = { primary: true, alt: true } + +export const shortcutRegistry = [ + { + id: 'shortcut-help', + action: 'toggle-shortcut-help', + name: '快捷键帮助', + description: '打开或关闭此快捷键面板', + category: 'general', + bindings: [ + { code: 'Slash', key: '?', shift: true, displayKey: '?' }, + { code: 'Slash', key: '/', primary: true, displayKey: '/' }, + ], + }, + { + id: 'show-desktop', + action: 'show-desktop', + name: '显示桌面', + description: '最小化当前窗口并返回桌面', + category: 'navigation', + bindings: [{ code: 'KeyD', key: 'd', ...modAlt, displayKey: 'D' }], + }, + { + id: 'dock-app', + action: 'focus-dock-app', + name: '聚焦 Dock 应用', + description: '按当前运行中的 Dock 顺序聚焦第 1–9 个应用', + category: 'navigation', + bindings: Array.from({ length: 9 }, (_, index) => ({ + code: `Digit${index + 1}`, + key: String(index + 1), + alt: true, + displayKey: String(index + 1), + argument: index, + })), + enabled: ({ dockApps = [] }, binding) => Boolean(dockApps[binding.argument]), + }, + { + id: 'window-minimize', + action: 'minimize-window', + name: '最小化当前应用', + description: '最小化当前活动窗口', + category: 'window', + bindings: [{ code: 'KeyM', key: 'm', ...modAlt, displayKey: 'M' }], + enabled: ({ activeId }) => Boolean(activeId), + }, + { + id: 'window-maximize', + action: 'toggle-maximized', + name: '最大化或还原', + description: '切换当前活动窗口的显示模式', + category: 'window', + bindings: [{ code: 'KeyF', key: 'f', ...modAlt, displayKey: 'F' }], + enabled: ({ activeId }) => Boolean(activeId), + }, + { + id: 'window-close', + action: 'close-window', + name: '关闭当前应用', + description: '关闭当前活动窗口并退出运行', + category: 'window', + bindings: [{ code: 'KeyW', key: 'w', ...modAlt, displayKey: 'W' }], + enabled: ({ activeId }) => Boolean(activeId), + }, +] + +export const shortcutCategories = [ + { id: 'general', label: '通用' }, + { id: 'navigation', label: '导航' }, + { id: 'window', label: '窗口' }, +] + +export function getPlatform(userAgent = navigator.userAgent, platform = navigator.platform) { + const value = `${userAgent} ${platform}`.toLowerCase() + if (/mac|iphone|ipad|ipod/.test(value)) return 'mac' + if (/win/.test(value)) return 'windows' + return 'linux' +} + +function matchesModifier(event, binding, key) { + return Boolean(event[key]) === Boolean(binding[key === 'ctrlKey' || key === 'metaKey' ? 'primary' : key.replace('Key', '')]) +} + +export function bindingMatches(event, binding) { + const primaryMatches = binding.primary + ? (event.ctrlKey || event.metaKey) && !(event.ctrlKey && event.metaKey) + : !event.ctrlKey && !event.metaKey + const hasUsableCode = event.code && event.code !== 'Unidentified' + const keyMatches = hasUsableCode ? event.code === binding.code : event.key === binding.key + return keyMatches + && primaryMatches + && matchesModifier(event, binding, 'altKey') + && matchesModifier(event, binding, 'shiftKey') +} + +export function isEditableContext(target) { + if (!(target instanceof Element)) return false + return Boolean(target.closest([ + 'input', + 'textarea', + 'select', + '[contenteditable]:not([contenteditable="false"])', + '[role="textbox"]', + '.xterm', + '[data-shortcut-scope]', + ].join(','))) +} + +export function matchShortcut(event, registry = shortcutRegistry, context = {}) { + if (event.repeat || event.isComposing || event.keyCode === 229 || isEditableContext(event.target)) return null + for (const shortcut of registry) { + for (const binding of shortcut.bindings) { + if (!bindingMatches(event, binding)) continue + if (shortcut.enabled && !shortcut.enabled(context, binding)) continue + return { shortcut, binding } + } + } + return null +} + +export function formatBinding(binding, platform = getPlatform()) { + const parts = [] + if (binding.primary) parts.push(platform === 'mac' ? '⌘' : 'Ctrl') + if (binding.alt) parts.push(platform === 'mac' ? 'Option' : 'Alt') + if (binding.shift && binding.displayKey !== '?') parts.push('Shift') + parts.push(binding.displayKey) + return parts.join(' + ') +} + +export function formatShortcut(shortcut, platform = getPlatform()) { + return shortcut.bindings.map(binding => formatBinding(binding, platform)).join(' / ') +} diff --git a/console-ui/src/shortcuts/shortcuts.test.js b/console-ui/src/shortcuts/shortcuts.test.js new file mode 100644 index 0000000..c50e893 --- /dev/null +++ b/console-ui/src/shortcuts/shortcuts.test.js @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { formatShortcut, isEditableContext, matchShortcut, shortcutRegistry } from './shortcuts' + +describe('keyboard shortcut matcher', () => { + it('matches KeyboardEvent.code instead of the visible key', () => { + const event = new KeyboardEvent('keydown', { code: 'Slash', key: '§', ctrlKey: true }) + expect(matchShortcut(event, shortcutRegistry, {})).toMatchObject({ shortcut: { id: 'shortcut-help' } }) + }) + + it.each([ + [{ code: '', key: 'm' }, 'window-minimize'], + [{ code: 'Unidentified', key: 'm' }, 'window-minimize'], + ])('falls back to the declared key when code is unavailable: %o', (identity, id) => { + const event = new KeyboardEvent('keydown', { ...identity, ctrlKey: true, altKey: true }) + expect(matchShortcut(event, shortcutRegistry, { activeId: 'dashboard' })?.shortcut.id).toBe(id) + }) + + it('prefers a usable code over the visible key', () => { + const event = new KeyboardEvent('keydown', { code: 'KeyW', key: 'm', ctrlKey: true, altKey: true }) + expect(matchShortcut(event, shortcutRegistry, { activeId: 'dashboard' })?.shortcut.id).toBe('window-close') + }) + + it('matches help by character semantics when code is unavailable', () => { + const event = new KeyboardEvent('keydown', { code: 'Unidentified', key: '?', shiftKey: true }) + expect(matchShortcut(event, shortcutRegistry, {})?.shortcut.id).toBe('shortcut-help') + }) + + it('does not match repeated keydown events by default', () => { + const event = new KeyboardEvent('keydown', { code: 'KeyD', ctrlKey: true, altKey: true, repeat: true }) + expect(matchShortcut(event, shortcutRegistry, {})).toBeNull() + }) + + it.each([ + { code: 'KeyD', key: 'd', ctrlKey: true, altKey: true, isComposing: true }, + { code: 'KeyD', key: 'd', ctrlKey: true, altKey: true, keyCode: 229 }, + ])('ignores IME composition keydown: %o', (init) => { + const event = new KeyboardEvent('keydown', init) + expect(matchShortcut(event, shortcutRegistry, {})).toBeNull() + }) + + it('only returns enabled shortcuts', () => { + const event = new KeyboardEvent('keydown', { code: 'KeyM', ctrlKey: true, altKey: true }) + expect(matchShortcut(event, shortcutRegistry, { activeId: null })).toBeNull() + expect(matchShortcut(event, shortcutRegistry, { activeId: 'dashboard' })?.shortcut.id).toBe('window-minimize') + }) +}) + +describe('editable shortcut contexts', () => { + it.each([ + ['input', ''], + ['textarea', ''], + ['select', ''], + ['contenteditable', '
'], + ['ARIA textbox', '
'], + ['xterm', '
'], + ['declared shortcut scope', '
'], + ])('suppresses shortcuts inside %s', (_label, markup) => { + document.body.innerHTML = markup + const target = document.body.querySelector('span, button, input, textarea, select, [contenteditable], [role="textbox"], .xterm') + expect(isEditableContext(target)).toBe(true) + }) + + it('allows shortcuts from ordinary controls', () => { + document.body.innerHTML = '' + expect(isEditableContext(document.querySelector('button'))).toBe(false) + }) +}) + +describe('platform shortcut labels', () => { + it('uses Apple modifier names on macOS', () => { + expect(formatShortcut(shortcutRegistry.find(item => item.id === 'window-close'), 'mac')).toBe('⌘ + Option + W') + }) + + it('uses Ctrl and Alt on Windows and Linux', () => { + expect(formatShortcut(shortcutRegistry.find(item => item.id === 'window-close'), 'windows')).toBe('Ctrl + Alt + W') + }) +}) + +describe('shortcut event consumption', () => { + it('leaves unmatched events untouched', () => { + const event = new KeyboardEvent('keydown', { code: 'KeyZ', ctrlKey: true, cancelable: true }) + const preventDefault = vi.spyOn(event, 'preventDefault') + expect(matchShortcut(event, shortcutRegistry, {})).toBeNull() + expect(preventDefault).not.toHaveBeenCalled() + }) +}) diff --git a/console-ui/src/shortcuts/useGlobalShortcuts.js b/console-ui/src/shortcuts/useGlobalShortcuts.js new file mode 100644 index 0000000..f13c2a9 --- /dev/null +++ b/console-ui/src/shortcuts/useGlobalShortcuts.js @@ -0,0 +1,19 @@ +import { useEffect, useEffectEvent } from 'react' +import { matchShortcut, shortcutRegistry } from './shortcuts' + +export function useGlobalShortcuts({ enabled, actions, context, registry = shortcutRegistry }) { + const onKeyDown = useEffectEvent((event) => { + if (!enabled) return + const match = matchShortcut(event, registry, context) + if (!match) return + const action = actions[match.shortcut.action] + if (typeof action !== 'function') return + event.preventDefault() + action(match.binding.argument, match) + }) + + useEffect(() => { + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, []) +} diff --git a/console-ui/src/shortcuts/useGlobalShortcuts.test.jsx b/console-ui/src/shortcuts/useGlobalShortcuts.test.jsx new file mode 100644 index 0000000..e7a1a1a --- /dev/null +++ b/console-ui/src/shortcuts/useGlobalShortcuts.test.jsx @@ -0,0 +1,70 @@ +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useGlobalShortcuts } from './useGlobalShortcuts' + +function Harness({ enabled = true, actions, context = {} }) { + useGlobalShortcuts({ enabled, actions, context }) + return null +} + +function keydown(init) { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) + document.dispatchEvent(event) + return event +} + +describe('useGlobalShortcuts', () => { + it('dispatches an enabled action and prevents its browser default', () => { + const actions = { 'show-desktop': vi.fn() } + render() + const event = keydown({ code: 'KeyD', ctrlKey: true, altKey: true }) + expect(actions['show-desktop']).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(true) + }) + + it('does not preventDefault when disabled or in an editable context', () => { + const actions = { 'show-desktop': vi.fn() } + const { rerender } = render() + expect(keydown({ code: 'KeyD', ctrlKey: true, altKey: true }).defaultPrevented).toBe(false) + expect(actions['show-desktop']).not.toHaveBeenCalled() + + rerender() + const input = document.createElement('input') + document.body.append(input) + const event = new KeyboardEvent('keydown', { code: 'KeyD', ctrlKey: true, altKey: true, bubbles: true, cancelable: true }) + input.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(actions['show-desktop']).not.toHaveBeenCalled() + }) + + it('handles keydown in the window capture phase before descendants stop propagation', () => { + const actions = { 'show-desktop': vi.fn() } + render() + const target = document.createElement('button') + target.addEventListener('keydown', event => event.stopPropagation()) + document.body.append(target) + target.dispatchEvent(new KeyboardEvent('keydown', { + code: 'KeyD', key: 'd', ctrlKey: true, altKey: true, bubbles: true, cancelable: true, + })) + expect(actions['show-desktop']).toHaveBeenCalledOnce() + }) + + it('suppresses repeated actions', () => { + const actions = { 'show-desktop': vi.fn() } + render() + const event = keydown({ code: 'KeyD', ctrlKey: true, altKey: true, repeat: true }) + expect(event.defaultPrevented).toBe(false) + expect(actions['show-desktop']).not.toHaveBeenCalled() + }) + + it.each([ + { code: 'KeyD', key: 'd', ctrlKey: true, altKey: true, isComposing: true }, + { code: 'KeyD', key: 'd', ctrlKey: true, altKey: true, keyCode: 229 }, + ])('ignores composition events: %o', (init) => { + const actions = { 'show-desktop': vi.fn() } + render() + const event = keydown(init) + expect(event.defaultPrevented).toBe(false) + expect(actions['show-desktop']).not.toHaveBeenCalled() + }) +}) diff --git a/console-ui/src/test/setup.js b/console-ui/src/test/setup.js new file mode 100644 index 0000000..a6dbe2b --- /dev/null +++ b/console-ui/src/test/setup.js @@ -0,0 +1,18 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() + document.body.style.overflow = '' + document.body.removeAttribute('aria-hidden') + document.body.removeAttribute('inert') +}) + +if (!Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'inert')) { + Object.defineProperty(HTMLElement.prototype, 'inert', { + configurable: true, + get() { return this.hasAttribute('inert') }, + set(value) { value ? this.setAttribute('inert', '') : this.removeAttribute('inert') }, + }) +} diff --git a/console-ui/vite.config.js b/console-ui/vite.config.js index 7b97ccf..95d233d 100644 --- a/console-ui/vite.config.js +++ b/console-ui/vite.config.js @@ -6,12 +6,19 @@ import { codeInspectorPlugin } from 'code-inspector-plugin' const apiTarget = process.env.DEVBOX_API_TARGET || 'http://localhost:9090' -export default defineConfig({ +export default defineConfig(({ mode }) => ({ + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.js', + css: false, + restoreMocks: true, + exclude: ['e2e/**', 'node_modules/**'], + }, plugins: [ - codeInspectorPlugin({ bundler: 'vite' }), - ccPromptPlugin({ inspector: false }), + mode !== 'test' && codeInspectorPlugin({ bundler: 'vite' }), + mode !== 'test' && ccPromptPlugin({ inspector: false }), react(), - ], + ].filter(Boolean), build: { outDir: 'dist', emptyOutDir: true, @@ -28,4 +35,4 @@ export default defineConfig({ '/app-icons': apiTarget, }, }, -}) +}))