diff --git a/package-lock.json b/package-lock.json index 6624018..779d3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.6.0", "license": "Apache-2.0", "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -17,6 +20,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@selfpatch/ros2-medkit-client-ts": "^0.6.0", "@tailwindcss/vite": "^4.1.14", + "@uiw/react-codemirror": "^4.25.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -31,6 +35,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.14", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", @@ -381,7 +386,6 @@ "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -445,6 +449,121 @@ "node": ">=18" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.7", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz", + "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1306,6 +1425,63 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "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/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4010,6 +4186,59 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", + "integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz", + "integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.11", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", @@ -4596,6 +4825,21 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4647,6 +4891,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6451,6 +6701,53 @@ "node": ">=0.10" } }, + "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/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7086,6 +7383,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7579,6 +7882,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 79ea1ce..4bede9e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc -b --emitDeclarationOnly false --noEmit", @@ -31,6 +33,9 @@ ] }, "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -39,6 +44,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@selfpatch/ros2-medkit-client-ts": "^0.6.0", "@tailwindcss/vite": "^4.1.14", + "@uiw/react-codemirror": "^4.25.11", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -53,6 +59,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.14", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/src/App.tsx b/src/App.tsx index d7a21a2..6496cc9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,20 +71,19 @@ function App() { } }, [selectedPath]); - // Auto-connect on mount if we have a stored URL (once only) + // Auto-connect on mount if we have a stored URL (once only). The ref guard + // alone survives React Strict Mode's mount-cleanup-remount cycle in dev; + // deferring the call via setTimeout previously let the Strict Mode cleanup + // cancel it before it ever fired, so connect() was never actually called. useEffect(() => { if (!serverUrl || isConnected || autoConnectAttempted.current) return; autoConnectAttempted.current = true; - const timeoutId = setTimeout(() => { - connect(serverUrl).then((success) => { - if (!success) { - setShowConnectionDialog(true); - } - }); - }, 0); - - return () => clearTimeout(timeoutId); + connect(serverUrl).then((success) => { + if (!success) { + setShowConnectionDialog(true); + } + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/AppsPanel.test.tsx b/src/components/AppsPanel.test.tsx new file mode 100644 index 0000000..d8fab21 --- /dev/null +++ b/src/components/AppsPanel.test.tsx @@ -0,0 +1,76 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { AppsPanel } from './AppsPanel'; + +vi.mock('@/components/ScriptsPanel', () => ({ + ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => ( +
{`${entityType}:${entityId}`}
+ ), +})); + +const mockState = { + selectEntity: vi.fn(), + configurations: new Map(), + fetchEntityData: vi.fn().mockResolvedValue([]), + fetchEntityOperations: vi.fn().mockResolvedValue([]), + listEntityFaults: vi.fn().mockResolvedValue({ items: [], count: 0 }), + scriptsSupported: false, + // EntityStatusControl reads these from the store; inert values keep the + // control mounted without reaching the network. + client: null, + statusByEntity: {}, + actuationByEntity: {}, + watchEntityStatus: vi.fn(() => () => {}), +}; + +vi.mock('@/lib/store', () => ({ + useAppStore: vi.fn((selector) => selector(mockState)), + entityStatusKey: (entityType: string, entityId: string) => `${entityType}:${entityId}`, +})); + +function renderAppsPanel(overrides: Partial = {}) { + Object.assign(mockState, { scriptsSupported: false }, overrides); + // App.tsx wraps the tree in a TooltipProvider, and the panel renders a + // tooltip for any lifecycle action the current status does not allow. + return render( + + + + ); +} + +describe('AppsPanel scripts tab', () => { + beforeEach(() => vi.clearAllMocks()); + + it('hides the Scripts tab when the gateway does not report the capability', async () => { + renderAppsPanel({ scriptsSupported: false }); + // Let the mount-time loadAppData effect settle before asserting, so its + // state updates don't land after the test body returns (act() warning). + await waitFor(() => { + expect(screen.queryByText(/Loading app resources/i)).not.toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /scripts/i })).not.toBeInTheDocument(); + }); + + it('shows the Scripts tab and renders its content when the capability is reported', async () => { + renderAppsPanel({ scriptsSupported: true }); + await userEvent.click(screen.getByRole('button', { name: /scripts/i })); + expect(screen.getByTestId('scripts-panel')).toHaveTextContent('apps:talker'); + }); +}); diff --git a/src/components/AppsPanel.tsx b/src/components/AppsPanel.tsx index d840fa4..8737fb7 100644 --- a/src/components/AppsPanel.tsx +++ b/src/components/AppsPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; import { AlertTriangle, Box, ChevronRight, Cpu, Database, FileCode, Network, Settings, Zap } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; @@ -9,6 +9,7 @@ import { RESOURCE_TABS, renderResourceTabContent, isResourceTabId, + SCRIPTS_TAB, type ResourceTabId, } from '@/components/ResourceTabs'; import { EntityStatusControl } from '@/components/EntityStatusControl'; @@ -22,7 +23,7 @@ interface TabConfig { icon: typeof Database; } -const APP_TABS: TabConfig[] = [{ id: 'overview', label: 'Overview', icon: Cpu }, ...RESOURCE_TABS]; +const BASE_APP_TABS: TabConfig[] = [{ id: 'overview', label: 'Overview', icon: Cpu }, ...RESOURCE_TABS]; interface AppsPanelProps { appId: string; @@ -51,16 +52,29 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI const [faults, setFaults] = useState([]); const [isLoading, setIsLoading] = useState(false); - const { selectEntity, configurations, fetchEntityData, fetchEntityOperations, listEntityFaults } = useAppStore( - useShallow((state) => ({ - selectEntity: state.selectEntity, - configurations: state.configurations, - fetchEntityData: state.fetchEntityData, - fetchEntityOperations: state.fetchEntityOperations, - listEntityFaults: state.listEntityFaults, - })) + const { selectEntity, configurations, fetchEntityData, fetchEntityOperations, listEntityFaults, scriptsSupported } = + useAppStore( + useShallow((state) => ({ + selectEntity: state.selectEntity, + configurations: state.configurations, + fetchEntityData: state.fetchEntityData, + fetchEntityOperations: state.fetchEntityOperations, + listEntityFaults: state.listEntityFaults, + scriptsSupported: state.scriptsSupported, + })) + ); + + const appTabs = useMemo( + () => (scriptsSupported ? [...BASE_APP_TABS, SCRIPTS_TAB] : BASE_APP_TABS), + [scriptsSupported] ); + // Fall back to the default tab when the Scripts tab disappears (e.g. the + // gateway capability flips off) while it is the active tab. + useEffect(() => { + if (!scriptsSupported && activeTab === 'scripts') setActiveTab('overview'); + }, [scriptsSupported, activeTab]); + // Load app resources on mount (configurations are loaded by ConfigurationPanel) useEffect(() => { const loadAppData = async () => { @@ -147,7 +161,7 @@ export function AppsPanel({ appId, appName, fqn, nodeName, namespace, componentI {/* Tab Navigation */}
- {APP_TABS.map((tab) => { + {appTabs.map((tab) => { const TabIcon = tab.icon; const isActive = activeTab === tab.id; let count = 0; diff --git a/src/components/EntityDetailPanel.test.tsx b/src/components/EntityDetailPanel.test.tsx index 2437c0a..3934431 100644 --- a/src/components/EntityDetailPanel.test.tsx +++ b/src/components/EntityDetailPanel.test.tsx @@ -13,7 +13,7 @@ // limitations under the License. import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { TooltipProvider } from '@/components/ui/tooltip'; import { EntityDetailPanel } from './EntityDetailPanel'; @@ -37,6 +37,11 @@ vi.mock('@/components/ResourceTabs', async () => { renderResourceTabContent: (tab: string) =>
, }; }); +vi.mock('@/components/ScriptsPanel', () => ({ + ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => ( +
{`${entityType}:${entityId}`}
+ ), +})); const mockPrefetchResourceCounts = vi.fn(); const mockFetchEntityData = vi.fn(); @@ -73,6 +78,7 @@ function setStore(overrides: Record) { statusByEntity: {}, actuationByEntity: {}, watchEntityStatus: vi.fn(() => () => {}), + scriptsSupported: false, ...overrides, }; } @@ -151,3 +157,57 @@ describe('EntityDetailPanel - nested entity types', () => { expect(screen.queryByText(/No detailed information available/i)).not.toBeInTheDocument(); }); }); + +describe('EntityDetailPanel - scripts tab gating (component view)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPrefetchResourceCounts.mockResolvedValue({ data: 0, operations: 0, configurations: 0, faults: 0, logs: 0 }); + mockFetchEntityData.mockResolvedValue([]); + }); + + it('hides the Scripts tab when the gateway does not report the capability', async () => { + setStore({ + selectedPath: '/server/area1/component1', + selectedEntity: { + id: 'component1', + name: 'component1', + type: 'component', + }, + scriptsSupported: false, + }); + + render( + + {}} /> + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Data/ })).toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: /scripts/i })).not.toBeInTheDocument(); + }); + + it('shows the Scripts tab and renders its content when the capability is reported', async () => { + setStore({ + selectedPath: '/server/area1/component1', + selectedEntity: { + id: 'component1', + name: 'component1', + type: 'component', + }, + scriptsSupported: true, + }); + + render( + + {}} /> + + ); + + const scriptsButton = await screen.findByRole('button', { name: /scripts/i }); + fireEvent.click(scriptsButton); + + expect(await screen.findByTestId('tab-content-scripts')).toBeInTheDocument(); + }); +}); diff --git a/src/components/EntityDetailPanel.tsx b/src/components/EntityDetailPanel.tsx index b5b39a4..6a419c5 100644 --- a/src/components/EntityDetailPanel.tsx +++ b/src/components/EntityDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useShallow } from 'zustand/shallow'; import { Copy, @@ -24,7 +24,7 @@ import { EntityDetailSkeleton } from '@/components/EntityDetailSkeleton'; import { DataPanel } from '@/components/DataPanel'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; -import { RESOURCE_TABS, renderResourceTabContent, type ResourceTabId } from '@/components/ResourceTabs'; +import { RESOURCE_TABS, renderResourceTabContent, SCRIPTS_TAB, type ResourceTabId } from '@/components/ResourceTabs'; import { AreasPanel } from '@/components/AreasPanel'; import { AppsPanel } from '@/components/AppsPanel'; import { FunctionsPanel } from '@/components/FunctionsPanel'; @@ -44,7 +44,7 @@ interface TabConfig { description?: string; } -const COMPONENT_TABS: TabConfig[] = RESOURCE_TABS; +const BASE_COMPONENT_TABS: TabConfig[] = RESOURCE_TABS; /** * Determine entity type for API calls based on entity type @@ -377,6 +377,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit configurations: 0, faults: 0, logs: 0, + scripts: 0, }); // Store fetched topics data for the Data tab. `null` means "not yet loaded // for the current entity" so the Data tab can render a skeleton instead of @@ -395,6 +396,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit refreshSelectedEntity, prefetchResourceCounts, fetchEntityData, + scriptsSupported, } = useAppStore( useShallow((state: AppState) => ({ selectedPath: state.selectedPath, @@ -407,9 +409,15 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit refreshSelectedEntity: state.refreshSelectedEntity, prefetchResourceCounts: state.prefetchResourceCounts, fetchEntityData: state.fetchEntityData, + scriptsSupported: state.scriptsSupported, })) ); + const componentTabs = useMemo( + () => (scriptsSupported ? [...BASE_COMPONENT_TABS, SCRIPTS_TAB] : BASE_COMPONENT_TABS), + [scriptsSupported] + ); + // Notify parent when entity is selected useEffect(() => { if (selectedPath && onEntitySelect) { @@ -417,6 +425,12 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit } }, [selectedPath, onEntitySelect]); + // Fall back to the default tab when the Scripts tab disappears (e.g. the + // gateway capability flips off) while it is the active tab. + useEffect(() => { + if (!scriptsSupported && activeTab === 'scripts') setActiveTab('data'); + }, [scriptsSupported, activeTab]); + // Reset the component-view resource tab to Data when the entity changes, // so switching between components doesn't show stale tab state. useEffect(() => { @@ -431,6 +445,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit configurations: 0, faults: 0, logs: 0, + scripts: 0, }; // Guard against late results from a previous entity overwriting the // current entity's state. The cleanup aborts in-flight requests AND @@ -483,7 +498,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit setTopicsData(fetchedData); // Use the already-fetched data length instead of a separate request - setResourceCounts({ ...counts, data: fetchedData.length, logs: 0 }); + setResourceCounts({ ...counts, data: fetchedData.length, logs: 0, scripts: 0 }); } catch { if (cancelled) return; // On unexpected failure fall back to "loaded empty" so the UI @@ -821,7 +836,7 @@ export function EntityDetailPanel({ onConnectClick, viewMode = 'entity', onEntit {isComponent && (
- {COMPONENT_TABS.map((tab) => { + {componentTabs.map((tab) => { const TabIcon = tab.icon; const isActive = activeTab === tab.id; const count = resourceCounts[tab.id]; diff --git a/src/components/EntityResourceTabs.tsx b/src/components/EntityResourceTabs.tsx index 8aba64b..0ea967a 100644 --- a/src/components/EntityResourceTabs.tsx +++ b/src/components/EntityResourceTabs.tsx @@ -23,6 +23,7 @@ interface LoadedResources { configurations: boolean; faults: boolean; logs: boolean; + scripts: boolean; } /** @@ -40,6 +41,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate configurations: false, faults: false, logs: false, + scripts: false, }); const loadedTabsRef = useRef(loadedTabs); loadedTabsRef.current = loadedTabs; @@ -141,6 +143,7 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate configurations: false, faults: false, logs: false, + scripts: false, }; setActiveTab('data'); setLoadedTabs(reset); diff --git a/src/components/ResourceTabs.test.tsx b/src/components/ResourceTabs.test.tsx new file mode 100644 index 0000000..2aacf5f --- /dev/null +++ b/src/components/ResourceTabs.test.tsx @@ -0,0 +1,47 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { isResourceTabId, renderResourceTabContent, RESOURCE_TABS, SCRIPTS_TAB } from './ResourceTabs'; + +vi.mock('@/components/ScriptsPanel', () => ({ + ScriptsPanel: ({ entityId, entityType }: { entityId: string; entityType: string }) => ( +
{`${entityType}:${entityId}`}
+ ), +})); + +describe('isResourceTabId', () => { + it('accepts scripts', () => expect(isResourceTabId('scripts')).toBe(true)); + it('rejects unknown ids', () => expect(isResourceTabId('nope')).toBe(false)); +}); + +describe('RESOURCE_TABS', () => { + it('does not include scripts so areas and functions never show it', () => { + expect(RESOURCE_TABS.map((t) => t.id)).toEqual(['data', 'operations', 'configurations', 'faults', 'logs']); + expect(SCRIPTS_TAB.id).toBe('scripts'); + }); +}); + +describe('renderResourceTabContent for scripts', () => { + it.each(['apps', 'components'] as const)('renders the scripts panel for %s', (entityType) => { + render(<>{renderResourceTabContent('scripts', 'e1', entityType)}); + expect(screen.getByTestId('scripts-panel')).toHaveTextContent(`${entityType}:e1`); + }); + + it.each(['areas', 'functions'] as const)('renders nothing for %s', (entityType) => { + const { container } = render(<>{renderResourceTabContent('scripts', 'e1', entityType)}); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/ResourceTabs.tsx b/src/components/ResourceTabs.tsx index 7fbd5c2..59eb680 100644 --- a/src/components/ResourceTabs.tsx +++ b/src/components/ResourceTabs.tsx @@ -13,11 +13,12 @@ // limitations under the License. import type { ReactNode } from 'react'; -import { AlertTriangle, Database, ScrollText, Settings, Zap } from 'lucide-react'; +import { AlertTriangle, Database, ScrollText, Settings, Terminal, Zap } from 'lucide-react'; import { ConfigurationPanel } from '@/components/ConfigurationPanel'; import { FaultsPanel } from '@/components/FaultsPanel'; import { LogsPanel } from '@/components/LogsPanel'; import { OperationsPanel } from '@/components/OperationsPanel'; +import { ScriptsPanel } from '@/components/ScriptsPanel'; import type { SovdResourceEntityType } from '@/lib/types'; /** @@ -32,11 +33,11 @@ import type { SovdResourceEntityType } from '@/lib/types'; * rendering stays per-panel because each entity type displays data * differently (apps: topics list, components: DataTabContent grid, * areas: aggregated grid). The helper `renderResourceTabContent` below - * therefore only handles operations / configurations / faults / logs; + * therefore only handles operations / configurations / faults / logs / scripts; * callers are responsible for rendering their own data tab content. */ -export type ResourceTabId = 'data' | 'operations' | 'configurations' | 'faults' | 'logs'; +export type ResourceTabId = 'data' | 'operations' | 'configurations' | 'faults' | 'logs' | 'scripts'; export interface ResourceTabConfig { id: ResourceTabId; @@ -52,8 +53,22 @@ export const RESOURCE_TABS: ResourceTabConfig[] = [ { id: 'logs', label: 'Logs', icon: ScrollText }, ]; +/** + * Not part of RESOURCE_TABS: script routes exist for apps and components only, + * and the tab additionally requires the gateway to report capabilities.scripts. + * Panels append it themselves. + */ +export const SCRIPTS_TAB: ResourceTabConfig = { id: 'scripts', label: 'Scripts', icon: Terminal }; + export function isResourceTabId(id: string): id is ResourceTabId { - return id === 'data' || id === 'operations' || id === 'configurations' || id === 'faults' || id === 'logs'; + return ( + id === 'data' || + id === 'operations' || + id === 'configurations' || + id === 'faults' || + id === 'logs' || + id === 'scripts' + ); } /** @@ -76,6 +91,10 @@ export function renderResourceTabContent( return ; case 'logs': return ; + case 'scripts': + return entityType === 'apps' || entityType === 'components' ? ( + + ) : null; case 'data': return null; } diff --git a/src/components/ScriptEditor.tsx b/src/components/ScriptEditor.tsx new file mode 100644 index 0000000..253dbef --- /dev/null +++ b/src/components/ScriptEditor.tsx @@ -0,0 +1,92 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useMemo, useState } from 'react'; +import CodeMirror, { EditorView } from '@uiw/react-codemirror'; +import type { Extension } from '@uiw/react-codemirror'; +import { python } from '@codemirror/lang-python'; +import { StreamLanguage } from '@codemirror/language'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { languageForFilename } from '@/lib/script-language'; + +interface ScriptEditorProps { + value: string; + onChange: (value: string) => void; + filename: string; + /** + * Accessible name for the editor's contenteditable region. CodeMirror's + * content element is the one that actually carries the textbox role, so + * a wrapping