From a9501d54761c11cf05bb7ff5a827332fc40c6617 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:17:32 +0200 Subject: [PATCH] fix(v5): backfill missing canvas uuid columns Add guarded migration for existing v5 application, cluster, and resource connection tables missing uuid columns. Add Vitest, jsdom, and React Testing Library coverage for optimistic updates, canvas hooks, ingress, resource merging, viewport behavior, and v5 browser canvas and cluster flows. --- ..._06_130000_v5_add_missing_uuid_columns.php | 35 + package-lock.json | 1057 ++++++++++++++++- package.json | 5 +- resources/js/v5/lib/optimistic.test.ts | 108 ++ .../v5/lib/use-application-ingress.test.tsx | 201 ++++ .../js/v5/lib/use-canvas-connections.test.tsx | 270 +++++ .../v5/lib/use-canvas-resource-merge.test.tsx | 178 +++ .../js/v5/lib/use-canvas-viewport.test.tsx | 172 +++ tests/v5/Browser/CanvasTest.php | 164 +++ tests/v5/Browser/ClustersTest.php | 72 ++ 10 files changed, 2234 insertions(+), 28 deletions(-) create mode 100644 database/migrations/2026_07_06_130000_v5_add_missing_uuid_columns.php create mode 100644 resources/js/v5/lib/optimistic.test.ts create mode 100644 resources/js/v5/lib/use-application-ingress.test.tsx create mode 100644 resources/js/v5/lib/use-canvas-connections.test.tsx create mode 100644 resources/js/v5/lib/use-canvas-resource-merge.test.tsx create mode 100644 resources/js/v5/lib/use-canvas-viewport.test.tsx create mode 100644 tests/v5/Browser/CanvasTest.php create mode 100644 tests/v5/Browser/ClustersTest.php diff --git a/database/migrations/2026_07_06_130000_v5_add_missing_uuid_columns.php b/database/migrations/2026_07_06_130000_v5_add_missing_uuid_columns.php new file mode 100644 index 000000000..e3888bf71 --- /dev/null +++ b/database/migrations/2026_07_06_130000_v5_add_missing_uuid_columns.php @@ -0,0 +1,35 @@ +string('uuid')->nullable()->unique(); + }); + } + } + + public function down(): void + { + // Intentionally left empty: the uuid columns belong to the create + // migrations; dropping them here could destroy live identifiers. + } +}; diff --git a/package-lock.json b/package-lock.json index 6a3fc221a..6422dd492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,16 +25,19 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^29.1.1", "laravel-vite-plugin": "3.1.0", "postcss": "8.5.15", "shadcn": "^4.11.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", "typescript": "^6.0.3", - "vite": "8.0.16" + "vite": "8.0.16", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -50,6 +53,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -81,6 +135,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -584,6 +639,161 @@ } } }, + "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "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.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "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.2.1" + }, + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "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", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.73.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.73.0.tgz", @@ -787,40 +997,34 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "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/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -1040,6 +1244,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1436,6 +1641,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/forms": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", @@ -1794,6 +2006,55 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/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/@ts-morph/common": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", @@ -1816,6 +2077,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/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1861,6 +2129,31 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", @@ -1874,6 +2167,7 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1884,6 +2178,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1916,6 +2211,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", @@ -1929,7 +2337,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "2.0.0", @@ -2010,6 +2419,19 @@ "node": ">=8" } }, + "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/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2017,6 +2439,26 @@ "dev": true, "license": "Python-2.0" }, + "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", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -2063,6 +2505,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2148,6 +2600,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2250,6 +2703,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2527,6 +2990,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2556,6 +3033,20 @@ "node": ">= 12" } }, + "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/debounce-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", @@ -2590,6 +3081,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -2668,6 +3166,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2687,6 +3195,13 @@ "node": ">=0.3.1" } }, + "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/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -2808,6 +3323,19 @@ "node": ">=8.6" } }, + "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/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -2848,6 +3376,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -2902,6 +3437,16 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2962,12 +3507,23 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3417,10 +3973,24 @@ "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.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-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3661,6 +4231,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3783,6 +4360,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4237,6 +4865,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "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", @@ -4257,6 +4895,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -4557,6 +5202,20 @@ "node": ">= 10" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4764,6 +5423,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4812,6 +5484,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4937,6 +5616,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -5005,6 +5699,16 @@ "node": ">= 0.10" } }, + "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/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -5073,6 +5777,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5082,6 +5787,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5089,6 +5795,13 @@ "react": "^19.2.7" } }, + "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/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -5286,6 +5999,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5514,6 +6240,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5553,6 +6286,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5563,6 +6303,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -5690,6 +6437,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/systeminformation": { "version": "5.31.7", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", @@ -5747,7 +6501,8 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -5770,6 +6525,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5786,6 +6558,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5809,6 +6611,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "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/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", @@ -5890,6 +6718,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6012,6 +6841,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6371,6 +7201,109 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -6381,6 +7314,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -6397,6 +7365,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6421,6 +7406,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6463,6 +7465,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index b8935ca99..64f87f63e 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,19 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", + "jsdom": "^29.1.1", "laravel-vite-plugin": "3.1.0", "postcss": "8.5.15", "shadcn": "^4.11.0", "tailwind-scrollbar": "4.0.2", "tailwindcss": "4.1.18", "typescript": "^6.0.3", - "vite": "8.0.16" + "vite": "8.0.16", + "vitest": "^4.1.10" }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/resources/js/v5/lib/optimistic.test.ts b/resources/js/v5/lib/optimistic.test.ts new file mode 100644 index 000000000..65fd0da78 --- /dev/null +++ b/resources/js/v5/lib/optimistic.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { runOptimisticUpdate } from '@/lib/optimistic'; + +describe('runOptimisticUpdate', () => { + it('applies, persists, and reconciles on success', async () => { + const calls: string[] = []; + const notify = vi.fn(); + + const succeeded = await runOptimisticUpdate({ + apply: () => calls.push('apply'), + rollback: () => calls.push('rollback'), + request: async () => { + calls.push('request'); + + return { ok: true, payload: 'server-state' }; + }, + fallbackErrorMessage: 'fallback', + notify, + onSuccess: (payload) => calls.push(`success:${payload}`), + onSettled: () => calls.push('settled'), + }); + + expect(succeeded).toBe(true); + expect(calls).toEqual(['apply', 'request', 'success:server-state', 'settled']); + expect(notify).not.toHaveBeenCalled(); + }); + + it('rolls back and notifies with the server error message on failure', async () => { + const rollback = vi.fn(); + const notify = vi.fn(); + + const succeeded = await runOptimisticUpdate({ + rollback, + request: async () => ({ ok: false, errorMessage: 'Ports must be integers.' }), + fallbackErrorMessage: 'fallback', + notify, + }); + + expect(succeeded).toBe(false); + expect(rollback).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledWith('Ports must be integers.'); + }); + + it('falls back to the generic message when the failure carries none', async () => { + const notify = vi.fn(); + + await runOptimisticUpdate({ + request: async () => ({ ok: false }), + fallbackErrorMessage: 'Could not save.', + notify, + }); + + expect(notify).toHaveBeenCalledWith('Could not save.'); + }); + + it('rolls back and notifies with the thrown error message', async () => { + const rollback = vi.fn(); + const notify = vi.fn(); + const onSettled = vi.fn(); + + const succeeded = await runOptimisticUpdate({ + rollback, + request: async () => { + throw new Error('Network down.'); + }, + fallbackErrorMessage: 'fallback', + notify, + onSettled, + }); + + expect(succeeded).toBe(false); + expect(rollback).toHaveBeenCalledOnce(); + expect(notify).toHaveBeenCalledWith('Network down.'); + expect(onSettled).toHaveBeenCalledOnce(); + }); + + it('uses the fallback message for non-Error throws', async () => { + const notify = vi.fn(); + + await runOptimisticUpdate({ + request: async () => { + throw 'string failure'; + }, + fallbackErrorMessage: 'Could not save.', + notify, + }); + + expect(notify).toHaveBeenCalledWith('Could not save.'); + }); + + it('runs onSettled even when onSuccess throws', async () => { + const onSettled = vi.fn(); + + await expect( + runOptimisticUpdate({ + request: async () => ({ ok: true, payload: undefined }), + fallbackErrorMessage: 'fallback', + notify: vi.fn(), + onSuccess: () => { + throw new Error('reconcile failed'); + }, + onSettled, + }), + ).resolves.toBe(false); + + expect(onSettled).toHaveBeenCalledOnce(); + }); +}); diff --git a/resources/js/v5/lib/use-application-ingress.test.tsx b/resources/js/v5/lib/use-application-ingress.test.tsx new file mode 100644 index 000000000..1812a0759 --- /dev/null +++ b/resources/js/v5/lib/use-application-ingress.test.tsx @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isValidDomain, useApplicationIngress } from '@/lib/use-application-ingress'; +import type { V5Application } from '@/types'; + +vi.mock('@/lib/canvas-api', () => ({ + canvasRequest: vi.fn(), +})); + +import { canvasRequest } from '@/lib/canvas-api'; + +const canvasRequestMock = vi.mocked(canvasRequest); + +function application(overrides: Partial = {}): V5Application { + return { + id: 'app-1', + name: 'nginx-test', + serverIngressEnabled: true, + ingressEnabled: false, + internalPort: null, + domains: [], + ...overrides, + } as V5Application; +} + +function jsonResponse(payload: unknown, ok = true): Response { + return { ok, json: async () => payload } as Response; +} + +function renderIngress(notify = vi.fn(), onApplicationUpdated = vi.fn()) { + const rendered = renderHook(() => useApplicationIngress({ notify, onApplicationUpdated })); + + return { ...rendered, notify, onApplicationUpdated }; +} + +beforeEach(() => { + canvasRequestMock.mockReset(); +}); + +describe('isValidDomain', () => { + it.each(['example.com', 'sub.example.com', 'a.io', 'my-app.example.co.uk'])('accepts %s', (domain) => { + expect(isValidDomain(domain)).toBe(true); + }); + + it.each(['', '.example.com', 'example.com.', '-bad.example.com', 'bad-.example.com', 'exa mple.com', 'UPPER.example.com'])( + 'rejects %j', + (domain) => { + expect(isValidDomain(domain)).toBe(false); + }, + ); +}); + +describe('toggleApplicationIngress', () => { + it('refuses to open the modal when server ingress is disabled', () => { + const { result, notify } = renderIngress(); + + act(() => result.current.toggleApplicationIngress(application({ serverIngressEnabled: false }))); + + expect(notify).toHaveBeenCalledWith('Enable ingress on the server before enabling app ingress.'); + expect(result.current.ingressModal).toBeNull(); + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); + + it('opens the modal prefilled from the application', () => { + const { result } = renderIngress(); + + act(() => + result.current.toggleApplicationIngress(application({ domains: ['a.example.com', 'b.example.com'], internalPort: 8080 })), + ); + + expect(result.current.ingressModal).toMatchObject({ + domains: 'a.example.com, b.example.com', + internalPort: '8080', + error: null, + }); + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); + + it('disables ingress immediately for an enabled application', async () => { + canvasRequestMock.mockResolvedValue(jsonResponse({ application: application() })); + const enabledApplication = application({ ingressEnabled: true, domains: ['app.example.com'], internalPort: 8080 }); + const { result, onApplicationUpdated } = renderIngress(); + + await act(() => result.current.toggleApplicationIngress(enabledApplication)); + + expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', { + method: 'PATCH', + body: { ingress_enabled: false, internal_port: 8080, domains: ['app.example.com'] }, + }); + expect(onApplicationUpdated).toHaveBeenCalledOnce(); + }); +}); + +describe('submitApplicationIngress', () => { + function openModal(result: { current: ReturnType }, app = application()): void { + act(() => result.current.toggleApplicationIngress(app)); + } + + it('requires at least one domain', async () => { + const { result } = renderIngress(); + + openModal(result); + await act(() => result.current.submitApplicationIngress()); + + expect(result.current.ingressModal?.error).toBe('Add at least one valid domain.'); + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); + + it('rejects invalid domains by name', async () => { + const { result } = renderIngress(); + + openModal(result); + act(() => result.current.setIngressModalDomains('good.example.com, bad_domain')); + act(() => result.current.setIngressModalInternalPort('8080')); + await act(() => result.current.submitApplicationIngress()); + + expect(result.current.ingressModal?.error).toBe('bad_domain is not a valid domain.'); + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); + + it.each(['', '0', '65536', 'abc'])('rejects invalid internal port %j', async (port) => { + const { result } = renderIngress(); + + openModal(result); + act(() => result.current.setIngressModalDomains('app.example.com')); + act(() => result.current.setIngressModalInternalPort(port)); + await act(() => result.current.submitApplicationIngress()); + + expect(result.current.ingressModal?.error).toBe('Choose a valid internal port between 1 and 65535.'); + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); + + it('persists deduplicated lowercase domains and closes the modal', async () => { + const updatedApplication = application({ ingressEnabled: true }); + canvasRequestMock.mockResolvedValue(jsonResponse({ application: updatedApplication })); + const { result, onApplicationUpdated } = renderIngress(); + + openModal(result); + act(() => result.current.setIngressModalDomains('App.Example.com, app.example.com, other.example.com,')); + act(() => result.current.setIngressModalInternalPort('8080')); + await act(() => result.current.submitApplicationIngress()); + + expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', { + method: 'PATCH', + body: { ingress_enabled: true, internal_port: 8080, domains: ['app.example.com', 'other.example.com'] }, + }); + expect(onApplicationUpdated).toHaveBeenCalledWith(updatedApplication); + expect(result.current.ingressModal).toBeNull(); + }); + + it('keeps the modal open and shows the server error on failure', async () => { + canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Domain already in use.' }, false)); + const { result, onApplicationUpdated } = renderIngress(); + + openModal(result); + act(() => result.current.setIngressModalDomains('app.example.com')); + act(() => result.current.setIngressModalInternalPort('8080')); + await act(() => result.current.submitApplicationIngress()); + + expect(result.current.ingressModal?.error).toBe('Domain already in use.'); + expect(onApplicationUpdated).not.toHaveBeenCalled(); + }); + + it('reports network failures inside the modal', async () => { + canvasRequestMock.mockRejectedValue(new Error('Network down.')); + const { result } = renderIngress(); + + openModal(result); + act(() => result.current.setIngressModalDomains('app.example.com')); + act(() => result.current.setIngressModalInternalPort('8080')); + await act(() => result.current.submitApplicationIngress()); + + expect(result.current.ingressModal?.error).toBe('Network down.'); + }); +}); + +describe('modal editing', () => { + it('clears the error when inputs change', async () => { + const { result } = renderIngress(); + + act(() => result.current.toggleApplicationIngress(application())); + await act(() => result.current.submitApplicationIngress()); + + expect(result.current.ingressModal?.error).not.toBeNull(); + + act(() => result.current.setIngressModalDomains('app.example.com')); + + expect(result.current.ingressModal?.error).toBeNull(); + }); + + it('closes the modal on demand', () => { + const { result } = renderIngress(); + + act(() => result.current.toggleApplicationIngress(application())); + act(() => result.current.closeIngressModal()); + + expect(result.current.ingressModal).toBeNull(); + }); +}); diff --git a/resources/js/v5/lib/use-canvas-connections.test.tsx b/resources/js/v5/lib/use-canvas-connections.test.tsx new file mode 100644 index 000000000..43e571736 --- /dev/null +++ b/resources/js/v5/lib/use-canvas-connections.test.tsx @@ -0,0 +1,270 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + activeConnectionPorts, + connectionDirectionKey, + pruneConnectionPortsByDirection, + useCanvasConnections, +} from '@/lib/use-canvas-connections'; +import type { V5ResourceConnection } from '@/types'; + +vi.mock('@/lib/canvas-api', () => ({ + canvasRequest: vi.fn(), +})); + +import { canvasRequest } from '@/lib/canvas-api'; + +const canvasRequestMock = vi.mocked(canvasRequest); + +function connection(overrides: Partial = {}): V5ResourceConnection { + return { + id: 'connection-1', + applicationIds: ['app-a', 'app-b'], + fromApplicationId: 'app-a', + toApplicationId: 'app-b', + portsByDirection: { 'app-a->app-b': ['80'] }, + ...overrides, + }; +} + +function jsonResponse(payload: unknown, ok = true): Response { + return { ok, json: async () => payload } as Response; +} + +beforeEach(() => { + canvasRequestMock.mockReset(); +}); + +describe('connection helpers', () => { + it('builds direction keys and reads active ports', () => { + expect(connectionDirectionKey('app-a', 'app-b')).toBe('app-a->app-b'); + expect(activeConnectionPorts(connection())).toEqual(['80']); + expect(activeConnectionPorts(connection({ portsByDirection: {} }))).toEqual([]); + }); + + it('prunes ports to the active direction only', () => { + const pruned = pruneConnectionPortsByDirection( + connection({ portsByDirection: { 'app-a->app-b': ['80'], 'app-b->app-a': ['443'] } }), + ); + + expect(pruned).toEqual({ 'app-a->app-b': ['80'] }); + }); +}); + +describe('connectionExists', () => { + it('matches connections in both directions', () => { + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + expect(result.current.connectionExists('app-a', 'app-b')).toBe(true); + expect(result.current.connectionExists('app-b', 'app-a')).toBe(true); + expect(result.current.connectionExists('app-a', 'app-c')).toBe(false); + }); +}); + +describe('persistNewConnection', () => { + it('adds and selects the persisted connection on success', async () => { + const persisted = connection({ id: 'connection-2', fromApplicationId: 'app-a', toApplicationId: 'app-c' }); + canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted })); + const notify = vi.fn(); + const { result } = renderHook(() => useCanvasConnections([connection()], notify)); + + await act(() => result.current.persistNewConnection('app-a', 'app-c')); + + expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections', { + method: 'POST', + body: { + resource_one: { type: 'application', uuid: 'app-a' }, + resource_two: { type: 'application', uuid: 'app-c' }, + }, + }); + expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']); + expect(result.current.selectedConnectionId).toBe('connection-2'); + expect(notify).toHaveBeenCalledWith(null); + expect(notify).not.toHaveBeenCalledWith(expect.stringContaining('Could not')); + }); + + it('notifies with message and detail on failure', async () => { + canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Connection rejected.', detail: 'Applications overlap.' }, false)); + const notify = vi.fn(); + const { result } = renderHook(() => useCanvasConnections([], notify)); + + await act(() => result.current.persistNewConnection('app-a', 'app-b')); + + expect(notify).toHaveBeenLastCalledWith('Connection rejected. Applications overlap.'); + expect(result.current.connections).toEqual([]); + }); +}); + +describe('addConnectionPort', () => { + it.each(['', '0', '65536', '8.5', 'http'])('ignores invalid port draft %j', (draft) => { + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.setConnectionPortDraft('connection-1', draft)); + act(() => result.current.addConnectionPort('connection-1')); + + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); + + it('ignores a port already present for the active direction', () => { + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.setConnectionPortDraft('connection-1', '80')); + act(() => result.current.addConnectionPort('connection-1')); + + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); + + it('optimistically adds the port, persists it, and clears the draft', async () => { + const persisted = connection({ portsByDirection: { 'app-a->app-b': ['80', '443'] } }); + canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted })); + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.setConnectionPortDraft('connection-1', ' 443 ')); + act(() => result.current.addConnectionPort('connection-1')); + + expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80', '443']); + expect(result.current.connectionPortInput['connection-1']).toBe(''); + + await waitFor(() => { + expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', { + method: 'PATCH', + body: { ports_by_direction: { 'app-a->app-b': [80, 443] } }, + }); + }); + }); + + it('rolls the port back when persistence fails', async () => { + canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Could not save allowed ports.' }, false)); + const notify = vi.fn(); + const { result } = renderHook(() => useCanvasConnections([connection()], notify)); + + act(() => result.current.setConnectionPortDraft('connection-1', '443')); + act(() => result.current.addConnectionPort('connection-1')); + + await waitFor(() => { + expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80']); + }); + expect(notify).toHaveBeenCalledWith('Could not save allowed ports.'); + }); +}); + +describe('removeConnectionPort', () => { + it('persists the connection without the removed port', async () => { + const persisted = connection({ portsByDirection: { 'app-a->app-b': [] } }); + canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted })); + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.removeConnectionPort('connection-1', '80')); + + expect(activeConnectionPorts(result.current.connections[0])).toEqual([]); + + await waitFor(() => { + expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', { + method: 'PATCH', + body: { ports_by_direction: { 'app-a->app-b': [] } }, + }); + }); + }); +}); + +describe('deleteConnection', () => { + it('optimistically removes the connection and keeps it removed on success', async () => { + canvasRequestMock.mockResolvedValue(jsonResponse({})); + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.setSelectedConnectionId('connection-1')); + act(() => result.current.deleteConnection('connection-1')); + + expect(result.current.connections).toEqual([]); + expect(result.current.selectedConnectionId).toBeNull(); + + await waitFor(() => { + expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', { method: 'DELETE' }); + }); + expect(result.current.connections).toEqual([]); + }); + + it('restores the connection at its original index when deletion fails', async () => { + canvasRequestMock.mockResolvedValue(jsonResponse({}, false)); + const first = connection({ id: 'connection-1' }); + const second = connection({ id: 'connection-2' }); + const notify = vi.fn(); + const { result } = renderHook(() => useCanvasConnections([first, second], notify)); + + act(() => result.current.deleteConnection('connection-1')); + + expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']); + + await waitFor(() => { + expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']); + }); + expect(notify).toHaveBeenCalledWith('Could not delete resource connection.'); + }); + + it('does nothing for an unknown connection id', () => { + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.deleteConnection('missing')); + + expect(canvasRequestMock).not.toHaveBeenCalled(); + expect(result.current.connections).toHaveLength(1); + }); +}); + +describe('updateConnectionDirection', () => { + it('swaps the active direction locally without a request', () => { + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.updateConnectionDirection('connection-1', 'app-b', 'app-a')); + + expect(result.current.connections[0]).toMatchObject({ fromApplicationId: 'app-b', toApplicationId: 'app-a' }); + expect(canvasRequestMock).not.toHaveBeenCalled(); + }); +}); + +describe('removeConnectionsForApplication', () => { + it('drops every connection touching the application', () => { + const related = connection({ id: 'connection-1' }); + const unrelated = connection({ + id: 'connection-2', + applicationIds: ['app-c', 'app-d'], + fromApplicationId: 'app-c', + toApplicationId: 'app-d', + }); + const { result } = renderHook(() => useCanvasConnections([related, unrelated], vi.fn())); + + act(() => result.current.removeConnectionsForApplication('app-a')); + + expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']); + }); +}); + +describe('keyboard deletion', () => { + it('deletes the selected connection on Backspace', async () => { + canvasRequestMock.mockResolvedValue(jsonResponse({})); + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + + act(() => result.current.setSelectedConnectionId('connection-1')); + act(() => { + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })); + }); + + expect(result.current.connections).toEqual([]); + }); + + it('ignores Backspace while typing in an input', () => { + const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn())); + const input = document.createElement('input'); + document.body.appendChild(input); + + act(() => result.current.setSelectedConnectionId('connection-1')); + act(() => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })); + }); + + expect(result.current.connections).toHaveLength(1); + input.remove(); + }); +}); diff --git a/resources/js/v5/lib/use-canvas-resource-merge.test.tsx b/resources/js/v5/lib/use-canvas-resource-merge.test.tsx new file mode 100644 index 000000000..5fffe45e5 --- /dev/null +++ b/resources/js/v5/lib/use-canvas-resource-merge.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCanvasResourceMerge } from '@/lib/use-canvas-resource-merge'; +import type { V5CanvasResourceUpdatedEvent } from '@/lib/use-canvas-channel'; +import type { V5Application, V5CaddyIngress } from '@/types'; + +const channelHandlers: Array<(event: V5CanvasResourceUpdatedEvent) => void> = []; + +vi.mock('@/lib/use-canvas-channel', () => ({ + useCanvasResourceChannel: (_teamId: number | null, onEvent: (event: V5CanvasResourceUpdatedEvent) => void) => { + channelHandlers.push(onEvent); + }, +})); + +function application(overrides: Partial = {}): V5Application { + return { + id: 'app-1', + name: 'nginx-test', + status: 'running', + projectUuid: 'project-1', + environmentUuid: 'env-1', + canvasX: 0, + canvasY: 0, + ...overrides, + } as V5Application; +} + +function ingress(overrides: Partial = {}): V5CaddyIngress { + return { + id: 'ingress-1', + name: 'caddy', + status: 'running', + canvasX: 0, + canvasY: 0, + ...overrides, + } as V5CaddyIngress; +} + +type HarnessOptions = { + initialApplications?: V5Application[]; + initialIngresses?: V5CaddyIngress[]; + selectedProjectUuid?: string | null; + selectedEnvironmentUuid?: string | null; + locallyPositionedApplicationIds?: Set; + locallyPositionedIngressIds?: Set; +}; + +function renderMergeHarness({ + initialApplications = [], + initialIngresses = [], + selectedProjectUuid = 'project-1', + selectedEnvironmentUuid = 'env-1', + locallyPositionedApplicationIds = new Set(), + locallyPositionedIngressIds = new Set(), +}: HarnessOptions = {}) { + let applications = initialApplications; + let ingresses = initialIngresses; + + const setApplications = (update: V5Application[] | ((current: V5Application[]) => V5Application[])): void => { + applications = typeof update === 'function' ? update(applications) : update; + }; + const setIngresses = (update: V5CaddyIngress[] | ((current: V5CaddyIngress[]) => V5CaddyIngress[])): void => { + ingresses = typeof update === 'function' ? update(ingresses) : update; + }; + + renderHook(() => + useCanvasResourceMerge({ + teamId: 1, + selectedProjectUuid, + selectedEnvironmentUuid, + setApplications, + setIngresses, + locallyPositionedApplicationIds: { current: locallyPositionedApplicationIds }, + locallyPositionedIngressIds: { current: locallyPositionedIngressIds }, + }), + ); + + const emit = (event: Partial): void => { + act(() => { + channelHandlers.at(-1)?.({ application: null, caddyIngress: null, ...event }); + }); + }; + + return { + emit, + applications: () => applications, + ingresses: () => ingresses, + }; +} + +beforeEach(() => { + channelHandlers.length = 0; +}); + +describe('useCanvasResourceMerge', () => { + it('updates an existing application in place', () => { + const harness = renderMergeHarness({ initialApplications: [application()] }); + + harness.emit({ application: application({ status: 'failed', canvasX: 100 }) }); + + expect(harness.applications()).toEqual([expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 100 })]); + }); + + it('keeps local canvas position for cards mid-drag', () => { + const harness = renderMergeHarness({ + initialApplications: [application({ canvasX: 500, canvasY: 300 })], + locallyPositionedApplicationIds: new Set(['app-1']), + }); + + harness.emit({ application: application({ status: 'failed', canvasX: 0, canvasY: 0 }) }); + + expect(harness.applications()).toEqual([ + expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 500, canvasY: 300 }), + ]); + }); + + it('appends unknown applications belonging to the selected project and environment', () => { + const harness = renderMergeHarness({ initialApplications: [application()] }); + + harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-1', environmentUuid: 'env-1' }) }); + + expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1', 'app-2']); + }); + + it('ignores unknown applications from other projects or environments', () => { + const harness = renderMergeHarness({ initialApplications: [application()] }); + + harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-9' }) }); + harness.emit({ application: application({ id: 'app-3', environmentUuid: 'env-9' }) }); + + expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1']); + }); + + it('never appends when no project or environment is selected', () => { + const harness = renderMergeHarness({ selectedProjectUuid: null, selectedEnvironmentUuid: null }); + + harness.emit({ application: application() }); + + expect(harness.applications()).toEqual([]); + }); + + it('merges bulk application payloads', () => { + const harness = renderMergeHarness({ initialApplications: [application()] }); + + harness.emit({ + applications: [application({ status: 'failed' }), application({ id: 'app-2' })], + }); + + expect(harness.applications()).toEqual([ + expect.objectContaining({ id: 'app-1', status: 'failed' }), + expect.objectContaining({ id: 'app-2' }), + ]); + }); + + it('updates known ingresses in place but never appends unknown ones', () => { + const harness = renderMergeHarness({ initialIngresses: [ingress()] }); + + harness.emit({ caddyIngress: ingress({ status: 'stopped' }) }); + harness.emit({ caddyIngress: ingress({ id: 'ingress-9' }) }); + + expect(harness.ingresses()).toEqual([expect.objectContaining({ id: 'ingress-1', status: 'stopped' })]); + }); + + it('keeps local ingress position for cards mid-drag', () => { + const harness = renderMergeHarness({ + initialIngresses: [ingress({ canvasX: 250, canvasY: 100 })], + locallyPositionedIngressIds: new Set(['ingress-1']), + }); + + harness.emit({ caddyIngress: ingress({ status: 'stopped', canvasX: 0, canvasY: 0 }) }); + + expect(harness.ingresses()).toEqual([ + expect.objectContaining({ id: 'ingress-1', status: 'stopped', canvasX: 250, canvasY: 100 }), + ]); + }); +}); diff --git a/resources/js/v5/lib/use-canvas-viewport.test.tsx b/resources/js/v5/lib/use-canvas-viewport.test.tsx new file mode 100644 index 000000000..b2fd0dba8 --- /dev/null +++ b/resources/js/v5/lib/use-canvas-viewport.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { PointerEvent, WheelEvent } from 'react'; + +import { APPLICATION_CARD_HEIGHT, APPLICATION_CARD_WIDTH } from '@/lib/canvas-geometry'; +import { MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM, PINCH_CANVAS_ZOOM_STEP, useCanvasViewport } from '@/lib/use-canvas-viewport'; + +const CANVAS_WIDTH = 800; +const CANVAS_HEIGHT = 600; + +function canvasElement(): HTMLDivElement { + const element = document.createElement('div'); + + element.getBoundingClientRect = () => + ({ left: 0, top: 0, width: CANVAS_WIDTH, height: CANVAS_HEIGHT, right: CANVAS_WIDTH, bottom: CANVAS_HEIGHT, x: 0, y: 0 }) as DOMRect; + + return element; +} + +function renderViewport() { + const rendered = renderHook(() => useCanvasViewport()); + + rendered.result.current.canvasRef.current = canvasElement(); + + return rendered; +} + +describe('zoomCanvas', () => { + it('zooms in by one step around the canvas center', () => { + const { result } = renderViewport(); + + act(() => result.current.zoomCanvas(1)); + + expect(result.current.viewport.zoom).toBeCloseTo(1.1); + // The canvas point under the viewport center must stay put. + expect((CANVAS_WIDTH / 2 - result.current.viewport.x) / result.current.viewport.zoom).toBeCloseTo(CANVAS_WIDTH / 2); + expect((CANVAS_HEIGHT / 2 - result.current.viewport.y) / result.current.viewport.zoom).toBeCloseTo(CANVAS_HEIGHT / 2); + }); + + it('keeps an explicit zoom origin stable', () => { + const { result } = renderViewport(); + const origin = { x: 100, y: 50 }; + + act(() => result.current.zoomCanvas(1, 0.5, origin)); + + const { x, y, zoom } = result.current.viewport; + + expect(zoom).toBeCloseTo(1.5); + expect((origin.x - x) / zoom).toBeCloseTo(origin.x); + expect((origin.y - y) / zoom).toBeCloseTo(origin.y); + }); + + it('clamps zoom to the maximum', () => { + const { result } = renderViewport(); + + act(() => result.current.zoomCanvas(1, 10)); + + expect(result.current.viewport.zoom).toBe(MAX_CANVAS_ZOOM); + }); + + it('clamps zoom to the minimum', () => { + const { result } = renderViewport(); + + act(() => result.current.zoomCanvas(-1, 10)); + + expect(result.current.viewport.zoom).toBe(MIN_CANVAS_ZOOM); + }); + + it('does nothing when the canvas element is not mounted', () => { + const { result } = renderHook(() => useCanvasViewport()); + + act(() => result.current.zoomCanvas(1)); + + expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: 1 }); + }); +}); + +describe('handleCanvasWheel', () => { + function wheelEvent(overrides: Partial<{ ctrlKey: boolean; deltaY: number; clientX: number; clientY: number }>) { + return { + ctrlKey: true, + deltaY: -1, + clientX: 0, + clientY: 0, + currentTarget: canvasElement(), + preventDefault: () => {}, + ...overrides, + } as unknown as WheelEvent; + } + + it('pinch-zooms in on ctrl+wheel up', () => { + const { result } = renderViewport(); + + act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: -1 }))); + + expect(result.current.viewport.zoom).toBeCloseTo(1 + PINCH_CANVAS_ZOOM_STEP); + }); + + it('pinch-zooms out on ctrl+wheel down', () => { + const { result } = renderViewport(); + + act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: 1 }))); + + expect(result.current.viewport.zoom).toBeCloseTo(1 - PINCH_CANVAS_ZOOM_STEP); + }); + + it('ignores plain scrolling without ctrl', () => { + const { result } = renderViewport(); + + act(() => result.current.handleCanvasWheel(wheelEvent({ ctrlKey: false }))); + + expect(result.current.viewport.zoom).toBe(1); + }); +}); + +describe('centerOnCanvasNodes', () => { + it('centers a single card in the canvas', () => { + const { result } = renderViewport(); + + act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], [])); + + expect(result.current.viewport).toEqual({ + x: CANVAS_WIDTH / 2 - APPLICATION_CARD_WIDTH / 2, + y: CANVAS_HEIGHT / 2 - APPLICATION_CARD_HEIGHT / 2, + zoom: 1, + }); + }); + + it('centers on the midpoint of multiple nodes', () => { + const { result } = renderViewport(); + + act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], [{ canvasX: 400, canvasY: 200 }])); + + expect(result.current.viewport).toEqual({ + x: CANVAS_WIDTH / 2 - (200 + APPLICATION_CARD_WIDTH / 2), + y: CANVAS_HEIGHT / 2 - (100 + APPLICATION_CARD_HEIGHT / 2), + zoom: 1, + }); + }); + + it('resets the pan but keeps the zoom when there are no nodes', () => { + const { result } = renderViewport(); + + act(() => result.current.zoomCanvas(1)); + act(() => result.current.centerOnCanvasNodes([], [])); + + expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: result.current.viewport.zoom }); + expect(result.current.viewport.x).toBe(0); + expect(result.current.viewport.y).toBe(0); + }); +}); + +describe('canvasPointFromPointer', () => { + function pointerEvent(clientX: number, clientY: number): PointerEvent { + return { clientX, clientY } as PointerEvent; + } + + it('maps client coordinates through pan and zoom', () => { + const { result } = renderViewport(); + + act(() => result.current.setViewport({ x: 100, y: 50, zoom: 2 })); + + expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 100, y: 100 }); + }); + + it('returns the origin when the canvas element is not mounted', () => { + const { result } = renderHook(() => useCanvasViewport()); + + expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 0, y: 0 }); + }); +}); diff --git a/tests/v5/Browser/CanvasTest.php b/tests/v5/Browser/CanvasTest.php new file mode 100644 index 000000000..0844b5ad4 --- /dev/null +++ b/tests/v5/Browser/CanvasTest.php @@ -0,0 +1,164 @@ + 0, 'is_sponsorship_popup_enabled' => false]); + + $this->user = User::factory()->create([ + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + $this->team = $this->user->teams()->firstOrFail(); + $this->project = Project::create([ + 'name' => 'Canvas Project', + 'team_id' => $this->team->id, + ]); + $this->environment = $this->project->environments()->firstOrFail(); +}); + +function createCanvasV5Server(array $attributes = []): V5Server +{ + return V5Server::query()->create([ + 'team_id' => test()->team->id, + 'created_by_user_id' => test()->user->id, + 'name' => 'edge-01', + 'host' => '203.0.113.10', + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'status' => 'installed', + 'last_bootstrapped_at' => now(), + ...$attributes, + ]); +} + +function createCanvasV5Application(V5Server $server, array $attributes = []): V5Application +{ + return V5Application::query()->create([ + 'team_id' => test()->team->id, + 'project_id' => test()->project->id, + 'environment_id' => test()->environment->id, + 'server_id' => $server->id, + 'created_by_user_id' => test()->user->id, + 'name' => 'nginx-test', + 'image' => 'docker.io/library/nginx:alpine', + 'container_name' => 'coolify-v5-nginx-'.strtolower((string) Str::ulid()), + 'status' => 'running', + 'mesh_namespace' => 'default', + 'canvas_x' => 0, + 'canvas_y' => 0, + ...$attributes, + ]); +} + +it('renders seeded applications on the canvas', function () { + $server = createCanvasV5Server(); + createCanvasV5Application($server); + + $this->actingAs($this->user); + + $page = visit('/v5'); + + $page->assertSee('nginx-test') + ->assertSee('docker.io/library/nginx:alpine') + ->assertSee('edge-01') + ->assertSee('Configure') + ->assertSee('1 apps') + ->assertDontSee('No applications on this canvas yet.') + ->assertNoJavaScriptErrors() + ->screenshot(); +}); + +it('shows the selected project and environment in the navbar', function () { + $this->actingAs($this->user); + + $page = visit('/v5'); + + $page->assertSee('Canvas Project') + ->assertSee('production') + ->assertNoJavaScriptErrors() + ->screenshot(); +}); + +it('deploys an nginx container from the toolbar', function () { + createCanvasV5Server(); + fakeSuccessfulNginxFluxDeployment(); + + $this->actingAs($this->user); + + $page = visit('/v5'); + + $page->assertSee('No applications on this canvas yet.') + ->click('Deploy') + ->assertSee('docker.io/library/nginx:alpine') + ->assertDontSee('No applications on this canvas yet.') + ->assertNoJavaScriptErrors() + ->screenshot(); + + $application = V5Application::query()->sole(); + + expect($application->image)->toBe('docker.io/library/nginx:alpine') + ->and($application->status)->toBe('running'); +}); + +it('disables app ingress from the inspector sheet', function () { + // Server ingress on, but not "installed", so the Caddy config sync is skipped. + $server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']); + $application = createCanvasV5Application($server, ['ingress_enabled' => true, 'internal_port' => 8080]); + V5ApplicationDomain::query()->create([ + 'application_id' => $application->id, + 'domain' => 'app.example.com', + ]); + + $this->actingAs($this->user); + + $page = visit('/v5'); + + $page->assertSee('nginx-test') + ->click('Configure') + ->assertSee('App configuration') + ->click('Networking') + ->assertSee('Public ingress') + ->click('[data-slot="sheet-content"] button:has-text("Disable")') + ->assertSee('Private') + ->assertNoJavaScriptErrors() + ->screenshot(); + + expect($application->refresh()->ingress_enabled)->toBeFalse(); +}); + +it('enables app ingress through the ingress dialog', function () { + $server = createCanvasV5Server(['is_ingress' => true, 'status' => 'installing']); + $application = createCanvasV5Application($server, ['ingress_enabled' => false]); + + $this->actingAs($this->user); + + $page = visit('/v5'); + + $page->assertSee('nginx-test') + ->click('button:has-text("Enable")') + ->assertSee('Enable app ingress') + ->fill('[placeholder="example.com, www.example.com"]', 'app.example.com') + ->fill('[placeholder="3000"]', '8080') + ->click('button:has-text("Enable ingress")') + ->assertDontSee('Ingress update failed') + ->assertNoJavaScriptErrors() + ->screenshot(); + + expect($application->refresh()->ingress_enabled)->toBeTrue() + ->and($application->internal_port)->toBe(8080) + ->and($application->domains()->pluck('domain')->all())->toBe(['app.example.com']); +}); diff --git a/tests/v5/Browser/ClustersTest.php b/tests/v5/Browser/ClustersTest.php new file mode 100644 index 000000000..5e6c2fb13 --- /dev/null +++ b/tests/v5/Browser/ClustersTest.php @@ -0,0 +1,72 @@ + 0, 'is_sponsorship_popup_enabled' => false]); + + $this->user = User::factory()->create([ + 'name' => 'Root User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); + $this->team = $this->user->teams()->firstOrFail(); +}); + +it('lists existing clusters', function () { + V5Cluster::query()->create([ + 'team_id' => $this->team->id, + 'created_by_user_id' => $this->user->id, + 'name' => 'Existing Mesh', + ]); + + $this->actingAs($this->user); + + $page = visit('/v5/clusters'); + + $page->assertSee('Clusters') + ->assertSee('Existing Mesh') + ->assertNoJavaScriptErrors() + ->screenshot(); +}); + +it('creates a cluster from the clusters page', function () { + $this->actingAs($this->user); + + $page = visit('/v5/clusters'); + + $page->assertSee('Clusters') + ->click('button[aria-label="Create cluster"]') + ->assertSee('Create cluster') + ->fill('[placeholder="Production Mesh"]', 'Test Mesh') + ->click('button:has-text("Create cluster")') + ->assertSee('Test Mesh') + ->assertNoJavaScriptErrors() + ->screenshot(); + + $cluster = V5Cluster::query()->sole(); + + expect($cluster->name)->toBe('Test Mesh') + ->and($cluster->team_id)->toBe($this->team->id); +}); + +it('shows a validation error when the cluster name is missing', function () { + $this->actingAs($this->user); + + $page = visit('/v5/clusters'); + + $page->click('button[aria-label="Create cluster"]') + ->click('button:has-text("Create cluster")') + ->assertSee('The name field is required.') + ->screenshot(); + + expect(V5Cluster::query()->count())->toBe(0); +});