diff --git a/apps/netscli-gui/package-lock.json b/apps/netscli-gui/package-lock.json index c3c3c4e1..42aa75cf 100644 --- a/apps/netscli-gui/package-lock.json +++ b/apps/netscli-gui/package-lock.json @@ -16,17 +16,69 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.11.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/selenium-webdriver": "^4.35.5", "@vitejs/plugin-react": "^6.0.1", "edgedriver": "^6.3.0", + "jsdom": "^26.1.0", "selenium-webdriver": "^4.44.0", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^4.1.7" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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/@bazel/runfiles": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", @@ -34,6 +86,121 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -624,6 +791,54 @@ "@tauri-apps/api": "^2.11.0" } }, + "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", + "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/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/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -635,6 +850,13 @@ "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" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -909,6 +1131,29 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -956,6 +1201,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -963,6 +1222,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -994,6 +1267,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1004,6 +1294,13 @@ "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" + }, "node_modules/edge-paths": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/edge-paths/-/edge-paths-3.0.5.tgz", @@ -1068,6 +1365,19 @@ "node": ">=20.0.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1168,6 +1478,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1196,6 +1519,19 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -1210,6 +1546,13 @@ "dev": true, "license": "ISC" }, + "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", @@ -1227,6 +1570,53 @@ "node": ">=20" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -1532,6 +1922,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lucide-react": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", @@ -1541,6 +1938,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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", + "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", @@ -1577,6 +1984,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1595,6 +2009,19 @@ "dev": true, "license": "(MIT AND Zlib)" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -1667,6 +2094,31 @@ "node": "^10 || ^12 || >=14" } }, + "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", + "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", + "engines": { + "node": ">=8" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -1674,6 +2126,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1695,6 +2157,13 @@ "react": "^19.2.8" } }, + "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" + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -1755,6 +2224,13 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -1785,6 +2261,26 @@ "safe-regex2": "bin/safe-regex2.js" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "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", @@ -1894,6 +2390,13 @@ ], "license": "MIT" }, + "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/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1938,6 +2441,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -1948,6 +2471,32 @@ "node": ">=14.14" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2152,6 +2701,67 @@ } } }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -2207,6 +2817,16 @@ } } }, + "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/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -2222,6 +2842,13 @@ "engines": { "node": ">=16.0.0" } + }, + "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" } } } diff --git a/apps/netscli-gui/package.json b/apps/netscli-gui/package.json index 7347cddd..e0003040 100644 --- a/apps/netscli-gui/package.json +++ b/apps/netscli-gui/package.json @@ -24,11 +24,14 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.11.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@types/selenium-webdriver": "^4.35.5", "@vitejs/plugin-react": "^6.0.1", "edgedriver": "^6.3.0", + "jsdom": "^26.1.0", "selenium-webdriver": "^4.44.0", "typescript": "^6.0.3", "vite": "^8.0.16", diff --git a/apps/netscli-gui/src/components/shell/AppDialogs.tsx b/apps/netscli-gui/src/components/shell/AppDialogs.tsx index 71b7d54a..ad5fe963 100644 --- a/apps/netscli-gui/src/components/shell/AppDialogs.tsx +++ b/apps/netscli-gui/src/components/shell/AppDialogs.tsx @@ -187,7 +187,7 @@ export function AppDialogs({ onSelectTab={workspace.setActiveTabId} onSelectRow={(tabId, rowIndex) => { workspace.setActiveTabId(tabId); - window.setTimeout(() => workspace.selectRow(rowIndex), 0); + workspace.selectRowInTab(tabId, rowIndex); }} /> )} diff --git a/apps/netscli-gui/src/hooks/useKeyboardShortcuts.ts b/apps/netscli-gui/src/hooks/useKeyboardShortcuts.ts index 06982aa2..0880ab9f 100644 --- a/apps/netscli-gui/src/hooks/useKeyboardShortcuts.ts +++ b/apps/netscli-gui/src/hooks/useKeyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import type { WorkspaceModel } from '../workspace/types'; @@ -25,6 +25,16 @@ export function useKeyboardShortcuts({ }) { const activeTab = workspace.activeTab; + // The handler is rebuilt every render (it closes over props that change), + // but the *listener* is attached once and reads the latest handler through + // this ref. + // + // Previously the deps array included `workspace` — a fresh object on every + // render — plus two fresh closures, so the 3-second network-stats poll + // re-rendered App and detached/reattached a document-level keydown listener + // continuously (B-18). + const handlerRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); + useEffect(() => { function isEditableTarget(target: EventTarget | null): boolean { return target instanceof HTMLElement && Boolean(target.closest('input,textarea,[contenteditable="true"]')); @@ -110,19 +120,15 @@ export function useKeyboardShortcuts({ } } - document.addEventListener('keydown', handleKeyboardShortcuts); - return () => document.removeEventListener('keydown', handleKeyboardShortcuts); - }, [ - activeTab, - focusResultFilter, - openMenu, - requestRun, - setOpenMenu, - setSettingsOpen, - settingsOpen, - setWorkspaceSearchOpen, - workspace, - workspaceSearchOpen, - ]); + // No deps: this runs after every render and only assigns a ref, which is + // far cheaper than swapping a DOM listener. + handlerRef.current = handleKeyboardShortcuts; + }); + + useEffect(() => { + const listener = (event: KeyboardEvent) => handlerRef.current?.(event); + document.addEventListener('keydown', listener); + return () => document.removeEventListener('keydown', listener); + }, []); } diff --git a/apps/netscli-gui/src/tools/presentation/table.test.ts b/apps/netscli-gui/src/tools/presentation/table.test.ts new file mode 100644 index 00000000..60141ab9 --- /dev/null +++ b/apps/netscli-gui/src/tools/presentation/table.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { filterAndSortRows } from './table'; +import type { ResultRow, WorkspaceTab } from '../types'; + +// Minimal rows: `filterAndSortRows` matches unkeyed terms against +// `searchText` and keyed terms against `data`. +const ROWS: ResultRow[] = [ + { data: { port: 22, service: 'ssh', status: 'open' }, searchText: '22 ssh open', raw: {} }, + { data: { port: 80, service: 'nginx', status: 'open' }, searchText: '80 nginx open', raw: {} }, + { data: { port: 443, service: 'nginx', status: 'open' }, searchText: '443 nginx open', raw: {} }, + { data: { port: 8080, service: 'proxy', status: 'closed' }, searchText: '8080 proxy closed', raw: {} }, +] as unknown as ResultRow[]; + +const TAB = { kind: 'scan', sortKey: undefined, sortDir: 'asc' } as unknown as WorkspaceTab; + +const filter = (query: string) => filterAndSortRows(ROWS, TAB, query); + +describe('result filter quoting', () => { + it('matches an unquoted term', () => { + expect(filter('open')).toHaveLength(3); + }); + + it('matches a fully quoted term with either quote style', () => { + expect(filter('"open"')).toHaveLength(3); + expect(filter("'open'")).toHaveLength(3); + }); + + // M-6. Typing `"open"` passes through `"open` on the way. That intermediate + // state used to tokenize as the literal `"open`, which matched nothing — so + // the table blinked to empty mid-keystroke and stayed there if the user + // never closed the quote. + // + // Reproduced live before the fix: open -> 4 rows, 'open' -> 4, 'open -> 0. + it('treats an unterminated quote as if it had not been typed yet', () => { + expect(filter('"open')).toHaveLength(3); + expect(filter("'open")).toHaveLength(3); + }); + + it('still splits terms while a quote is dangling', () => { + // `"nginx open` must behave as two terms, not one literal phrase. + expect(filter('"nginx open')).toHaveLength(2); + }); + + it('keeps a closing quote from swallowing the term', () => { + expect(filter('open"')).toHaveLength(3); + }); + + it('does not treat a mid-word apostrophe as a quote', () => { + // A lone apostrophe inside a word must stay part of the search text, + // otherwise names like o'brien become unsearchable. + expect(filter("don't")).toHaveLength(0); + expect(filter('nginx')).toHaveLength(2); + }); + + it('ignores a lone quote character rather than matching nothing', () => { + expect(filter('"')).toHaveLength(ROWS.length); + expect(filter("'")).toHaveLength(ROWS.length); + }); + + it('supports quoted phrases containing spaces', () => { + // A quoted phrase is one contiguous substring match against searchText. + expect(filter('"nginx open"')).toHaveLength(2); + expect(filter('"443 nginx"')).toHaveLength(1); + // Non-adjacent words do not match as a phrase, unlike two bare terms. + expect(filter('"open 443"')).toHaveLength(0); + expect(filter('open 443')).toHaveLength(1); + }); +}); diff --git a/apps/netscli-gui/src/tools/presentation/table.ts b/apps/netscli-gui/src/tools/presentation/table.ts index fe73953e..c901ba5e 100644 --- a/apps/netscli-gui/src/tools/presentation/table.ts +++ b/apps/netscli-gui/src/tools/presentation/table.ts @@ -68,17 +68,22 @@ function parseFilter(input: string): FilterToken[] { function tokenizeFilter(query: string): string[] { const tokens: string[] = []; + const chars = Array.from(query); let current = ''; let quote: '"' | "'" | null = null; + let quoteStart = -1; - for (const char of query) { + for (let index = 0; index < chars.length; index += 1) { + const char = chars[index]; if ((char === '"' || char === "'") && quote === null) { quote = char; + quoteStart = index; current += char; continue; } if (char === quote) { quote = null; + quoteStart = -1; current += char; continue; } @@ -90,6 +95,20 @@ function tokenizeFilter(query: string): string[] { current += char; } + // An unterminated quote means the user is mid-typing on the way to a closing + // one. Treating it as a real quote made the whole tail a single literal + // token — so `'open` searched for `'open` and silently returned no rows + // where `open` returned four (M-6). + // + // Re-tokenizing without the dangling quote makes typing `"open 22` behave + // like `open 22` until the quote is closed. Each pass removes one character, + // so the recursion terminates. A quote *inside* a word (`don't`) never opens + // one here, so apostrophes in search text still work. + if (quote !== null && quoteStart >= 0) { + chars.splice(quoteStart, 1); + return tokenizeFilter(chars.join('')); + } + if (current.trim()) tokens.push(current.trim()); return tokens; } @@ -112,9 +131,11 @@ function keysFor(key: string): string[] { } function stripQuotes(value: string): string { + // The length guard matters: a lone `"` both starts and ends with a quote, + // and `slice(1, -1)` on it would silently produce an empty term. if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ) { return value.slice(1, -1); } diff --git a/apps/netscli-gui/src/workspace/transfer.ts b/apps/netscli-gui/src/workspace/transfer.ts index 6006f4a1..edffcd13 100644 --- a/apps/netscli-gui/src/workspace/transfer.ts +++ b/apps/netscli-gui/src/workspace/transfer.ts @@ -128,9 +128,13 @@ export function exportSelectedRows( ); } -export async function copyRowsDetails(rows: ResultRow[]) { - if (rows.length === 0) return; - const details = rows +// These build the text and nothing more. They used to write to the clipboard +// themselves and swallow the outcome, which left the caller reporting success +// unconditionally (B-19). Copy reporting now lives in one place, in +// `useResultActions.copyToClipboard`. + +export function formatRowsDetails(rows: ResultRow[]): string { + return rows .map((row, index) => { const body = Object.entries(row.data) .map(([key, value]) => `${key}: ${value ?? ''}`) @@ -138,13 +142,11 @@ export async function copyRowsDetails(rows: ResultRow[]) { return rows.length === 1 ? body : `Row ${index + 1}\n${body}`; }) .join('\n\n'); - await navigator.clipboard?.writeText(details).catch(() => undefined); } -export async function copyRowsRaw(rows: ResultRow[]) { - if (rows.length === 0) return; +export function formatRowsRaw(rows: ResultRow[]): string { const payload = rows.length === 1 ? rows[0].raw : rows.map((row) => row.raw); - await navigator.clipboard?.writeText(JSON.stringify(payload, null, 2)).catch(() => undefined); + return JSON.stringify(payload, null, 2); } function exportText( diff --git a/apps/netscli-gui/src/workspace/types.ts b/apps/netscli-gui/src/workspace/types.ts index 86f4f523..c5c066b5 100644 --- a/apps/netscli-gui/src/workspace/types.ts +++ b/apps/netscli-gui/src/workspace/types.ts @@ -26,6 +26,8 @@ export interface WorkspaceModel { patchTab: (id: string, patch: Partial) => void; patchForm: (id: string, key: string, value: string) => void; selectRow: (index: number, mode?: RowSelectionMode) => void; + /** Select a row in a named tab, which need not be the active one. */ + selectRowInTab: (tabId: string, index: number) => void; selectAllRows: () => void; addTab: (kind: ToolKind) => void; openHostTool: (kind: 'scan' | 'inspect', host: string) => void; diff --git a/apps/netscli-gui/src/workspace/useResultActions.ts b/apps/netscli-gui/src/workspace/useResultActions.ts index ccc315f8..47e8c145 100644 --- a/apps/netscli-gui/src/workspace/useResultActions.ts +++ b/apps/netscli-gui/src/workspace/useResultActions.ts @@ -5,8 +5,8 @@ import { createTab, TOOL_CONFIG } from '../tools/registry'; import type { ResultColumn, ResultRow, WorkspaceTab } from '../tools/types'; import { buildResultBundle, - copyRowsDetails, - copyRowsRaw, + formatRowsDetails, + formatRowsRaw, exportCurrentResult, exportSelectedRows, parseResultBundle, @@ -94,16 +94,37 @@ export function useResultActions({ } } + /** + * Copy text, reporting whether it actually landed. + * + * The previous shape — `navigator.clipboard?.writeText(x).catch(() => + * undefined)` followed by an unconditional "copied" toast — reported + * success in two failing cases: a rejected write (swallowed by the catch) + * and a missing `navigator.clipboard` (the optional chain made it a silent + * no-op). The export path already reports its failures; this matches it. + */ + async function copyToClipboard(label: string, text: string) { + if (!navigator.clipboard) { + showToast({ message: `${label} failed: clipboard unavailable`, kind: 'interaction' }); + return; + } + try { + await navigator.clipboard.writeText(text); + showToast({ message: `${label} copied`, kind: 'interaction' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showToast({ message: `${label} copy failed: ${message}`, kind: 'interaction' }); + } + } + async function copyCommand() { if (!commandPreview) return; - await navigator.clipboard?.writeText(commandPreview).catch(() => undefined); - showToast({ message: 'Command copied', kind: 'interaction' }); + await copyToClipboard('Command', commandPreview); } async function copyCellValue(label: string, value: string) { if (!value) return; - await navigator.clipboard?.writeText(value).catch(() => undefined); - showToast({ message: `${label} copied`, kind: 'interaction' }); + await copyToClipboard(label, value); } async function openCaptureFile(path: string) { @@ -123,21 +144,15 @@ export function useResultActions({ async function copySelectedDetails() { const rowsToCopy = selectedRows.length > 0 ? selectedRows : selectedRow ? [selectedRow] : []; if (rowsToCopy.length === 0) return; - await copyRowsDetails(rowsToCopy); - showToast({ - message: rowsToCopy.length === 1 ? 'Details copied' : `${rowsToCopy.length} rows copied`, - kind: 'interaction', - }); + const label = rowsToCopy.length === 1 ? 'Details' : `${rowsToCopy.length} rows`; + await copyToClipboard(label, formatRowsDetails(rowsToCopy)); } async function copySelectedRaw() { const rowsToCopy = selectedRows.length > 0 ? selectedRows : selectedRow ? [selectedRow] : []; if (rowsToCopy.length === 0) return; - await copyRowsRaw(rowsToCopy); - showToast({ - message: rowsToCopy.length === 1 ? 'Raw row copied' : `Raw ${rowsToCopy.length} rows copied`, - kind: 'interaction', - }); + const label = rowsToCopy.length === 1 ? 'Raw row' : `Raw ${rowsToCopy.length} rows`; + await copyToClipboard(label, formatRowsRaw(rowsToCopy)); } return { diff --git a/apps/netscli-gui/src/workspace/useSelection.ts b/apps/netscli-gui/src/workspace/useSelection.ts new file mode 100644 index 00000000..0e5a9f54 --- /dev/null +++ b/apps/netscli-gui/src/workspace/useSelection.ts @@ -0,0 +1,121 @@ +import { buildRows, filterAndSortRows } from '../tools/presentation'; +import type { ResultRow, RowSelectionMode, WorkspaceTab } from '../tools/types'; +import type { DefaultInterfaceInfo } from '../types/netscli'; +import { enrichInterfaceRows } from './interfaceRows'; +import { clampIndex, normalizeSelection, rangeBetween } from './selection'; + +/** + * Row-selection commands for the workspace. + * + * Split out of `useWorkspace` because that hook had grown past the + * maintainability cap; the size gate's own note named selection handling as + * the next thing to extract. It is a natural seam — everything here is a + * command that reads a tab and writes a selection patch. + */ +export function useSelection({ + activeTab, + defaultInterface, + filterText, + patchTab, + rows, + tabs, + trafficInterfaceName, +}: { + activeTab: WorkspaceTab | undefined; + defaultInterface: DefaultInterfaceInfo | null; + filterText: string; + patchTab: (id: string, patch: Partial) => void; + rows: ResultRow[]; + tabs: WorkspaceTab[]; + trafficInterfaceName: string | null; +}) { + function selectRow(index: number, mode: RowSelectionMode = 'single') { + if (!activeTab) return; + const rowCount = rows.length; + if (rowCount === 0) return; + + const nextIndex = clampIndex(index, rowCount); + const currentSelection = normalizeSelection( + activeTab.selectedIndices, + activeTab.selectedIndex, + rowCount, + ); + const anchor = clampIndex(activeTab.selectionAnchor ?? activeTab.selectedIndex, rowCount); + + if (mode === 'range') { + patchTab(activeTab.id, { + selectedIndex: nextIndex, + selectedIndices: rangeBetween(anchor, nextIndex), + selectionAnchor: anchor, + }); + return; + } + + if (mode === 'toggle') { + const selected = new Set(currentSelection); + if (selected.has(nextIndex) && selected.size > 1) { + selected.delete(nextIndex); + } else { + selected.add(nextIndex); + } + patchTab(activeTab.id, { + selectedIndex: nextIndex, + selectedIndices: Array.from(selected).sort((left, right) => left - right), + selectionAnchor: nextIndex, + }); + return; + } + + if (mode === 'focus') { + patchTab(activeTab.id, { selectedIndex: nextIndex }); + return; + } + + patchTab(activeTab.id, { + selectedIndex: nextIndex, + selectedIndices: [nextIndex], + selectionAnchor: nextIndex, + }); + } + + /** + * Select a row in an explicitly named tab, which may not be the active one. + * + * The workspace-search "jump to result" path used to call `setActiveTabId` + * and then `setTimeout(() => selectRow(i), 0)`. The timeout fired after + * commit but still held the *pre-switch* closure, so it patched the + * previous tab and clamped against the previous tab's row count (A-13). + * + * Addressing the tab by id removes the ordering dependency: rows for the + * destination tab are derived here rather than read from the active-tab + * memo, so the index is clamped against the right list. + */ + function selectRowInTab(tabId: string, index: number) { + const tab = tabs.find((item) => item.id === tabId); + if (!tab) return; + const tabRows = filterAndSortRows( + enrichInterfaceRows(buildRows(tab.result ?? null), defaultInterface?.name, trafficInterfaceName), + tab, + filterText, + ); + if (tabRows.length === 0) return; + const nextIndex = clampIndex(index, tabRows.length); + patchTab(tabId, { + selectedIndex: nextIndex, + selectedIndices: [nextIndex], + selectionAnchor: nextIndex, + }); + } + + function selectAllRows() { + if (!activeTab || rows.length === 0) return; + patchTab(activeTab.id, { + selectedIndex: 0, + selectedIndices: rows.map((_row, index) => index), + selectionAnchor: 0, + detailTab: rows.length > 1 ? 'selection' : activeTab.detailTab, + }); + } + + return { selectRow, selectRowInTab, selectAllRows }; +} diff --git a/apps/netscli-gui/src/workspace/useTabLifecycle.ts b/apps/netscli-gui/src/workspace/useTabLifecycle.ts index b60a1df3..2f52cc3f 100644 --- a/apps/netscli-gui/src/workspace/useTabLifecycle.ts +++ b/apps/netscli-gui/src/workspace/useTabLifecycle.ts @@ -66,16 +66,20 @@ export function useTabLifecycle({ function closeTab(id: string) { cancelOperationIds([id]); - setTabs((prev) => { - const index = prev.findIndex((tab) => tab.id === id); - if (index < 0) return prev; - const next = prev.filter((tab) => tab.id !== id); - if (id === activeTabId) { - const replacement = next[Math.max(0, index - 1)] ?? next[0]; - setActiveTabId(replacement?.id ?? ''); - } - return next; - }); + + // The replacement id is computed out here rather than inside the + // `setTabs` updater. React requires updaters to be pure, and calling + // `setActiveTabId` from within one relied on that call being idempotent + // — StrictMode double-invokes updaters in dev, and concurrent rendering + // may replay them (B-17). + const index = tabs.findIndex((tab) => tab.id === id); + if (index < 0) return; + const remaining = tabs.filter((tab) => tab.id !== id); + if (id === activeTabId) { + const replacement = remaining[Math.max(0, index - 1)] ?? remaining[0]; + setActiveTabId(replacement?.id ?? ''); + } + setTabs(remaining); } function closeAllTabs() { diff --git a/apps/netscli-gui/src/workspace/useWorkspace.test.tsx b/apps/netscli-gui/src/workspace/useWorkspace.test.tsx new file mode 100644 index 00000000..f421968b --- /dev/null +++ b/apps/netscli-gui/src/workspace/useWorkspace.test.tsx @@ -0,0 +1,232 @@ +// @vitest-environment jsdom +// +// Regression coverage for the workspace hook. +// +// This file exists because B-27 found zero React hook or component tests +// against 87 modules — and A-13, B-16 and B-17 all lived in that gap. Each +// case below was reproduced by hand in the running GUI before being written +// down here. + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useWorkspace } from './useWorkspace'; + +// The hook reaches for Tauri and for persisted history on mount; neither +// exists under jsdom. Stub them so the tests exercise state logic only. +vi.mock('../services/env', () => ({ isTauri: () => false })); +vi.mock('../services/netscli', () => ({ + listenOperationProgress: vi.fn(() => Promise.resolve(() => undefined)), + cancelOperation: vi.fn(() => Promise.resolve()), + runOperation: vi.fn(() => Promise.resolve(null)), + openFilesystemPath: vi.fn(() => Promise.resolve()), +})); +vi.mock('./historyStorage', () => ({ + loadHistory: () => [], + saveHistory: () => undefined, +})); +vi.mock('./useNetworkStatus', () => ({ + useNetworkStatus: () => ({ + defaultInterface: undefined, + interfaces: [], + networkStats: null, + setTrafficInterfaceName: () => undefined, + statusInterfaceInfo: null, + trafficInterface: undefined, + trafficInterfaceName: undefined, + }), +})); + +const options = { persistentHistory: false, maxConcurrentProbes: 64 }; + +function renderWorkspace() { + return renderHook(() => useWorkspace(options as never)); +} + +// Selection logic clamps against the row count, so a tab with no result has +// zero rows and every selection call short-circuits. Tests that assert on a +// selected index must give the tab real rows or they pass vacuously. +const SCAN_PORTS = [22, 80, 443, 8080, 8443].map((port) => ({ + port, + open: true, + status: 'open', + service: `svc-${port}`, + latency_ms: 1, + banner: '', +})); + +function scanResult() { + return { kind: 'scan', data: SCAN_PORTS } as never; +} + +describe('tab selection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // B-16: the reset effect keyed on `sortKey`/`sortDir`, and each tool kind + // has a different DEFAULT_SORT, so merely switching tabs looked like a + // re-sort and wiped the destination tab's selection. + // + // Reproduced live: scan -> ARP -> scan dropped a 3-row selection to 0, + // while scan -> scan -> scan kept all 3. + it('keeps a tab selection when switching away and back', async () => { + const { result } = renderWorkspace(); + + const firstTabId = result.current.activeTabId; + act(() => { + result.current.patchTab(firstTabId, { result: scanResult() }); + }); + act(() => { + result.current.addTab('arp'); + }); + const secondTabId = result.current.activeTabId; + expect(secondTabId).not.toBe(firstTabId); + + act(() => { + result.current.patchTab(firstTabId, { selectedIndex: 2, selectedIndices: [2], selectionAnchor: 2 }); + }); + + act(() => { + result.current.setActiveTabId(firstTabId); + }); + + await waitFor(() => { + const tab = result.current.tabs.find((item) => item.id === firstTabId); + expect(tab?.selectedIndices).toEqual([2]); + }); + }); + + // Re-sorting the *same* tab must still reset, because the row at a given + // index becomes a different row. This is the behaviour the B-16 fix had to + // preserve while dropping the cross-tab reset. + it('resets the selection when the active tab is re-sorted', async () => { + const { result } = renderWorkspace(); + const tabId = result.current.activeTabId; + + act(() => { + result.current.patchTab(tabId, { selectedIndex: 3, selectedIndices: [3], selectionAnchor: 3 }); + }); + act(() => { + result.current.patchTab(tabId, { sortKey: 'port', sortDir: 'desc' }); + }); + + await waitFor(() => { + const tab = result.current.tabs.find((item) => item.id === tabId); + expect(tab?.selectedIndices).toEqual([0]); + }); + }); + + // A-13: workspace search jumped to a row via `setActiveTabId` followed by + // `setTimeout(() => selectRow(i), 0)`. The timeout kept the pre-switch + // closure, so it patched the *previous* tab. + // + // Reproduced live: parked on ARP, searched `nginx` (row index 1), the jump + // switched tabs correctly but selected index 0. + it('selects a row in a named tab without touching the active one', async () => { + const { result } = renderWorkspace(); + const firstTabId = result.current.activeTabId; + + // Give the target tab rows, otherwise the clamp short-circuits and this + // assertion would hold no matter which tab got patched. + act(() => { + result.current.patchTab(firstTabId, { result: scanResult() }); + }); + act(() => { + result.current.addTab('arp'); + }); + const secondTabId = result.current.activeTabId; + expect(result.current.activeTabId).toBe(secondTabId); + + act(() => { + result.current.selectRowInTab(firstTabId, 3); + }); + + await waitFor(() => { + const target = result.current.tabs.find((item) => item.id === firstTabId); + const other = result.current.tabs.find((item) => item.id === secondTabId); + // The *named* tab moved, even though a different tab is active. Under + // the old stale-closure path this landed on the active tab instead. + expect(target?.selectedIndex).toBe(3); + expect(target?.selectedIndices).toEqual([3]); + expect(other?.selectedIndex).toBe(0); + }); + }); + + it('clamps a jump against the target tab row count, not the active one', async () => { + const { result } = renderWorkspace(); + const firstTabId = result.current.activeTabId; + act(() => { + result.current.patchTab(firstTabId, { result: scanResult() }); + }); + act(() => { + result.current.addTab('arp'); + }); + + act(() => { + result.current.selectRowInTab(firstTabId, 99); + }); + + await waitFor(() => { + const target = result.current.tabs.find((item) => item.id === firstTabId); + // 5 fixture rows, so the clamp lands on the last index. + expect(target?.selectedIndex).toBe(SCAN_PORTS.length - 1); + }); + }); + + it('ignores a jump to a tab that no longer exists', () => { + const { result } = renderWorkspace(); + expect(() => { + act(() => { + result.current.selectRowInTab('no-such-tab', 3); + }); + }).not.toThrow(); + }); +}); + +describe('tab lifecycle', () => { + // B-17: `setActiveTabId` was called from inside a `setTabs` updater. React + // requires updaters to be pure; StrictMode double-invokes them in dev. + // Under StrictMode a non-idempotent updater would land on the wrong tab. + it('picks the correct replacement tab when the active tab is closed', async () => { + const { result } = renderWorkspace(); + const first = result.current.activeTabId; + + act(() => { + result.current.addTab('arp'); + }); + const second = result.current.activeTabId; + act(() => { + result.current.addTab('dns'); + }); + const third = result.current.activeTabId; + + act(() => { + result.current.closeTab(third); + }); + + await waitFor(() => { + expect(result.current.tabs.map((tab) => tab.id)).toEqual([first, second]); + // Closing the last tab falls back to its left-hand neighbour. + expect(result.current.activeTabId).toBe(second); + }); + }); + + it('closing a background tab leaves the active tab active', async () => { + const { result } = renderWorkspace(); + const first = result.current.activeTabId; + act(() => { + result.current.addTab('arp'); + }); + const second = result.current.activeTabId; + + act(() => { + result.current.closeTab(first); + }); + + await waitFor(() => { + expect(result.current.activeTabId).toBe(second); + expect(result.current.tabs).toHaveLength(1); + }); + }); +}); diff --git a/apps/netscli-gui/src/workspace/useWorkspace.ts b/apps/netscli-gui/src/workspace/useWorkspace.ts index 0f9f3682..f21ff847 100644 --- a/apps/netscli-gui/src/workspace/useWorkspace.ts +++ b/apps/netscli-gui/src/workspace/useWorkspace.ts @@ -1,18 +1,19 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { isTauri } from '../services/env'; import * as netscli from '../services/netscli'; import { createTab, defaultDetailTab } from '../tools/registry'; import { buildCommand, buildRows, columnsFor, filterAndSortRows } from '../tools/presentation'; -import type { HistoryEntry, ResultColumn, RowSelectionMode, WorkspaceTab } from '../tools/types'; +import type { HistoryEntry, ResultColumn, WorkspaceTab } from '../tools/types'; import { applyContextDefaults } from './networkDefaults'; import { createDemoScreenshotTabs, isDemoScreenshotMode } from './demoMode'; import { cancelWorkspaceTab, runWorkspaceTab } from './operations'; -import { clampIndex, normalizeSelection, rangeBetween } from './selection'; +import { clampIndex, normalizeSelection } from './selection'; import { loadHistory, saveHistory } from './historyStorage'; import { enrichInterfaceRows } from './interfaceRows'; import { applyProgressUpdate } from './traceProgress'; import { useResultActions } from './useResultActions'; +import { useSelection } from './useSelection'; import { useTabLifecycle } from './useTabLifecycle'; import type { WorkspaceModel, WorkspaceOptions } from './types'; import { useNetworkStatus } from './useNetworkStatus'; @@ -73,6 +74,16 @@ export function useWorkspace(options: WorkspaceOptions): WorkspaceModel { interfaces, }); + const { selectRow, selectRowInTab, selectAllRows } = useSelection({ + activeTab, + defaultInterface, + filterText, + patchTab, + rows, + tabs, + trafficInterfaceName, + }); + const resultActions = useResultActions({ activeTab, columns, @@ -106,30 +117,66 @@ export function useWorkspace(options: WorkspaceOptions): WorkspaceModel { }); }, [defaultInterface, interfaces, trafficInterfaceName]); + // Reset the selection when the *same* tab is re-sorted or re-filtered, + // because the row at a given index is then a different row. + // + // Keying this on `sortKey`/`sortDir` alone was wrong: each tool kind has + // its own DEFAULT_SORT, so merely switching tabs changed those values and + // wiped the destination tab's selection (B-16). Comparing against the + // previous tab id distinguishes "this tab re-sorted" from "a different + // tab became active". + const lastSortRef = useRef<{ tabId: string; sortKey?: string; sortDir?: string; filterText: string } | null>(null); useEffect(() => { if (!activeTab) return; + const prev = lastSortRef.current; + const next = { + tabId: activeTab.id, + sortKey: activeTab.sortKey, + sortDir: activeTab.sortDir, + filterText, + }; + lastSortRef.current = next; + + // First render, or the active tab changed: adopt the new state without + // touching the selection the destination tab already had. + if (!prev || prev.tabId !== next.tabId) return; + if (prev.sortKey === next.sortKey && prev.sortDir === next.sortDir && prev.filterText === next.filterText) { + return; + } + patchTab(activeTab.id, { selectedIndex: 0, selectedIndices: [0], selectionAnchor: 0, }); - }, [activeTab?.sortDir, activeTab?.sortKey, filterText]); + }, [activeTab?.id, activeTab?.sortDir, activeTab?.sortKey, filterText]); useEffect(() => { if (!isTauri()) return; let cancelled = false; let unlisten: (() => void) | undefined; - void netscli.listenOperationProgress((progress) => { - const tabId = Object.entries(activeOps.current).find(([, opId]) => opId === progress.op_id)?.[0]; - if (!tabId) return; - setTabs((prev) => prev.map((tab) => (tab.id === tabId ? applyProgressUpdate(tab, progress) : tab))); - }).then((dispose) => { - if (cancelled) { - dispose(); - } else { - unlisten = dispose; - } - }); + void netscli + .listenOperationProgress((progress) => { + const tabId = Object.entries(activeOps.current).find(([, opId]) => opId === progress.op_id)?.[0]; + if (!tabId) return; + setTabs((prev) => prev.map((tab) => (tab.id === tabId ? applyProgressUpdate(tab, progress) : tab))); + }) + .then((dispose) => { + if (cancelled) { + dispose(); + } else { + unlisten = dispose; + } + }) + // Without this, a rejected `listen()` left every later operation + // silently reporting no progress, with nothing surfaced to the user + // (B-15). Every other Tauri call in the codebase is already guarded. + .catch((error: unknown) => { + showToast({ + kind: 'interaction', + message: `Progress updates unavailable: ${error instanceof Error ? error.message : String(error)}`, + }); + }); return () => { cancelled = true; unlisten?.(); @@ -158,65 +205,6 @@ export function useWorkspace(options: WorkspaceOptions): WorkspaceModel { ); } - function selectRow(index: number, mode: RowSelectionMode = 'single') { - if (!activeTab) return; - const rowCount = rows.length; - if (rowCount === 0) return; - - const nextIndex = clampIndex(index, rowCount); - const currentSelection = normalizeSelection( - activeTab.selectedIndices, - activeTab.selectedIndex, - rowCount, - ); - const anchor = clampIndex(activeTab.selectionAnchor ?? activeTab.selectedIndex, rowCount); - - if (mode === 'range') { - patchTab(activeTab.id, { - selectedIndex: nextIndex, - selectedIndices: rangeBetween(anchor, nextIndex), - selectionAnchor: anchor, - }); - return; - } - - if (mode === 'toggle') { - const selected = new Set(currentSelection); - if (selected.has(nextIndex) && selected.size > 1) { - selected.delete(nextIndex); - } else { - selected.add(nextIndex); - } - patchTab(activeTab.id, { - selectedIndex: nextIndex, - selectedIndices: Array.from(selected).sort((left, right) => left - right), - selectionAnchor: nextIndex, - }); - return; - } - - if (mode === 'focus') { - patchTab(activeTab.id, { selectedIndex: nextIndex }); - return; - } - - patchTab(activeTab.id, { - selectedIndex: nextIndex, - selectedIndices: [nextIndex], - selectionAnchor: nextIndex, - }); - } - - function selectAllRows() { - if (!activeTab || rows.length === 0) return; - patchTab(activeTab.id, { - selectedIndex: 0, - selectedIndices: rows.map((_row, index) => index), - selectionAnchor: 0, - detailTab: rows.length > 1 ? 'selection' : activeTab.detailTab, - }); - } - function closeOtherTabs() { closeOtherTabsFor(activeTab); } @@ -297,6 +285,7 @@ export function useWorkspace(options: WorkspaceOptions): WorkspaceModel { patchTab, patchForm, selectRow, + selectRowInTab, selectAllRows, addTab, openHostTool, diff --git a/apps/netscli-gui/vite.config.ts b/apps/netscli-gui/vite.config.ts index eb6e1f5e..99d8d6b5 100644 --- a/apps/netscli-gui/vite.config.ts +++ b/apps/netscli-gui/vite.config.ts @@ -28,4 +28,12 @@ export default defineConfig({ interval: 1000, }, }, + test: { + // Most existing suites are pure modules and need no DOM, so jsdom is + // opted into per-file with `@vitest-environment jsdom` rather than paid + // for globally. Hook and component tests declare it; the ~57 pure tests + // keep running in node. + environment: 'node', + globals: true, + }, })