From 9b39ee6b66892c5501128accc7aa705aeb78277e Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Fri, 14 Aug 2026 23:17:56 +0100 Subject: [PATCH 1/2] Fix GUI state bugs and add React hook test coverage B-27 found zero React hook or component tests against 87 modules. A-13, B-16 and B-17 all lived in that gap, which is why they survived. Adds @testing-library/react + jsdom (opted into per-file, so the ~57 existing pure tests keep running in node) and lands each fix with a test that was verified to fail against the old code. A-13: workspace search jumped to a row via setActiveTabId followed by 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 that tab's row count. Reproduced live: parked on ARP, searched nginx (row index 1), the jump switched tabs correctly but selected index 0. Replaced with selectRowInTab(tabId, index), which derives the destination tab's rows and has no ordering dependency. B-16: the selection-reset effect keyed on sortKey/sortDir, and each tool kind has its own 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. Now compares the previous tab id so re-sorting the same tab still resets, which is the behaviour that had to be preserved. B-17: setActiveTabId was called from inside a setTabs updater. React requires updaters to be pure and StrictMode double-invokes them in dev; this worked only because the call happened to be idempotent. The replacement id is now computed outside the updater. B-18: the global keydown listener was torn down and re-attached on every render -- deps included a fresh object and two fresh closures, and a 3-second stats poll re-renders App continuously. The handler is now read through a ref and the listener attaches once. B-15: the progress-event subscription had no .catch, so a rejected listen() left every later operation silently reporting no progress. Every other Tauri call in the codebase was already guarded. B-19: "Copied" was reported even when the write failed -- .catch(() => undefined) swallowed the rejection and the optional chain made a missing navigator.clipboard a silent no-op. All four copy paths now route through one helper that reports failure, matching what export already did. The transfer.ts helpers become pure text builders. M-6: an unterminated quote made the whole tail one literal token, so typing "open on the way to "open" silently matched nothing. Reproduced live: open -> 4 rows, 'open' -> 4, 'open -> 0. The tokenizer now re-tokenizes without the dangling quote, so it behaves as if the quote had not been typed yet. A mid-word apostrophe (don't) still never opens a quote. useWorkspace crossed the file-size cap, so selection handling moves to useSelection.ts -- the split the gate's own note prescribed. 72 tests pass, up from 57. --- apps/netscli-gui/package-lock.json | 711 ++++++++++++++++++ apps/netscli-gui/package.json | 3 + .../src/components/shell/AppDialogs.tsx | 2 +- .../src/hooks/useKeyboardShortcuts.ts | 36 +- .../src/tools/presentation/table.test.ts | 69 ++ .../src/tools/presentation/table.ts | 27 +- apps/netscli-gui/src/workspace/transfer.ts | 16 +- apps/netscli-gui/src/workspace/types.ts | 2 + .../src/workspace/useResultActions.ts | 47 +- .../netscli-gui/src/workspace/useSelection.ts | 121 +++ .../src/workspace/useTabLifecycle.ts | 24 +- .../src/workspace/useWorkspace.test.tsx | 232 ++++++ .../netscli-gui/src/workspace/useWorkspace.ts | 137 ++-- apps/netscli-gui/vite.config.ts | 8 + 14 files changed, 1309 insertions(+), 126 deletions(-) create mode 100644 apps/netscli-gui/src/tools/presentation/table.test.ts create mode 100644 apps/netscli-gui/src/workspace/useSelection.ts create mode 100644 apps/netscli-gui/src/workspace/useWorkspace.test.tsx diff --git a/apps/netscli-gui/package-lock.json b/apps/netscli-gui/package-lock.json index c3c3c4e1..96bac48d 100644 --- a/apps/netscli-gui/package-lock.json +++ b/apps/netscli-gui/package-lock.json @@ -16,17 +16,88 @@ }, "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": "^30.0.1", "selenium-webdriver": "^4.44.0", "typescript": "^6.0.3", "vite": "^8.0.16", "vitest": "^4.1.7" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "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 +105,159 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -68,6 +292,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -624,6 +866,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 +925,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 +1206,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", @@ -919,6 +1239,16 @@ "node": ">=12" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -956,6 +1286,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -963,6 +1307,35 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -994,6 +1367,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 +1394,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 +1465,19 @@ "node": ">=20.0.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1168,6 +1578,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1210,6 +1633,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 +1657,54 @@ "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": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -1532,6 +2010,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", @@ -1541,6 +2029,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", @@ -1551,6 +2049,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1595,6 +2100,19 @@ "dev": true, "license": "(MIT AND Zlib)" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -1667,6 +2185,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 +2217,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 +2248,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", @@ -1711,6 +2271,16 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ret": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", @@ -1785,6 +2355,19 @@ "safe-regex2": "bin/safe-regex2.js" } }, + "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 +2477,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 +2528,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -1948,6 +2558,32 @@ "node": ">=14.14" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1970,6 +2606,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -2152,6 +2798,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -2207,6 +2901,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 +2926,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..dd3fc4ab 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": "^30.0.1", "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, + }, }) From 4dd75bfe37973a6655505550f6d5103b64358a84 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Fri, 14 Aug 2026 23:27:26 +0100 Subject: [PATCH 2/2] Pin jsdom to a version that supports CI's Node 20 jsdom 30 requires Node ^22.22.2 || ^24.15.0 || >=26, and CI runs Node 20, so it failed with `webidl.util.markAsUncloneable is not a function` from undici. It only passed locally by luck -- this host is on 24.14.1, also below jsdom 30's floor, and npm had warned EBADENGINE at install time. jsdom 26 declares >=18. Verified by running the suite under Node 20 directly rather than trusting the range: 72 tests pass. --- apps/netscli-gui/package-lock.json | 410 ++++++++++++----------------- apps/netscli-gui/package.json | 2 +- 2 files changed, 164 insertions(+), 248 deletions(-) diff --git a/apps/netscli-gui/package-lock.json b/apps/netscli-gui/package-lock.json index 96bac48d..42aa75cf 100644 --- a/apps/netscli-gui/package-lock.json +++ b/apps/netscli-gui/package-lock.json @@ -23,7 +23,7 @@ "@types/selenium-webdriver": "^4.35.5", "@vitejs/plugin-react": "^6.0.1", "edgedriver": "^6.3.0", - "jsdom": "^30.0.1", + "jsdom": "^26.1.0", "selenium-webdriver": "^4.44.0", "typescript": "^6.0.3", "vite": "^8.0.16", @@ -31,36 +31,17 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", - "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "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": "^3.3.0", - "@csstools/css-color-parser": "^4.1.10", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" - }, - "engines": { - "node": "^22.13.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", - "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" - }, - "engines": { - "node": "^22.13.0 || >=24.0.0" + "@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": { @@ -105,23 +86,10 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "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": [ { @@ -135,13 +103,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=20.19.0" + "node": ">=18" } }, "node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "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": [ { @@ -155,17 +123,17 @@ ], "license": "MIT", "engines": { - "node": ">=20.19.0" + "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", - "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "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": [ { @@ -179,21 +147,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.3.0" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { - "node": ">=20.19.0" + "node": ">=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "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": [ { @@ -207,41 +175,16 @@ ], "license": "MIT", "engines": { - "node": ">=20.19.0" + "node": ">=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", - "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } + "@csstools/css-tokenizer": "^3.0.4" } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "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": [ { @@ -255,7 +198,7 @@ ], "license": "MIT", "engines": { - "node": ">=20.19.0" + "node": ">=18" } }, "node_modules/@emnapi/core": { @@ -292,24 +235,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1239,16 +1164,6 @@ "node": ">=12" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1286,18 +1201,18 @@ "dev": true, "license": "MIT" }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "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": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">=18" } }, "node_modules/csstype": { @@ -1308,32 +1223,17 @@ "license": "MIT" }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "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": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" } }, "node_modules/debug": { @@ -1466,13 +1366,13 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "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": ">=20.19.0" + "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -1579,16 +1479,16 @@ } }, "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "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": { - "@exodus/bytes": "^1.6.0" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" } }, "node_modules/http-proxy-agent": { @@ -1619,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", @@ -1665,39 +1578,38 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", - "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^6.0.5", - "@asamuzakjp/dom-selector": "^8.3.0", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.7", - "@exodus/bytes": "^1.15.1", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "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", - "lru-cache": "^11.5.2", - "parse5": "^8.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": "^6.0.2", - "undici": "^8.9.0", + "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.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": "^22.22.2 || ^24.15.0 || >=26.0.0" + "node": ">=18" }, "peerDependencies": { - "canvas": "^3.2.3" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -2011,14 +1923,11 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "license": "ISC" }, "node_modules/lucide-react": { "version": "1.17.0", @@ -2049,13 +1958,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2082,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", @@ -2101,13 +2010,13 @@ "license": "(MIT AND Zlib)" }, "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "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": "^8.0.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -2271,16 +2180,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ret": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", @@ -2325,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", @@ -2355,6 +2261,13 @@ "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", @@ -2529,22 +2442,22 @@ } }, "node_modules/tldts": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", - "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "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": "^7.4.10" + "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", - "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "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" }, @@ -2559,29 +2472,29 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "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": "^7.0.5" + "tldts": "^6.1.32" }, "engines": { "node": ">=16" } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "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": ">=20" + "node": ">=18" } }, "node_modules/tslib": { @@ -2606,16 +2519,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -2812,38 +2715,51 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "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": ">=20" + "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": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "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": ">=20" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", - "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "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": { - "@exodus/bytes": "^1.15.1", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": "^22.14.0 || >=24.0.0" + "node": ">=18" } }, "node_modules/which": { diff --git a/apps/netscli-gui/package.json b/apps/netscli-gui/package.json index dd3fc4ab..e0003040 100644 --- a/apps/netscli-gui/package.json +++ b/apps/netscli-gui/package.json @@ -31,7 +31,7 @@ "@types/selenium-webdriver": "^4.35.5", "@vitejs/plugin-react": "^6.0.1", "edgedriver": "^6.3.0", - "jsdom": "^30.0.1", + "jsdom": "^26.1.0", "selenium-webdriver": "^4.44.0", "typescript": "^6.0.3", "vite": "^8.0.16",