diff --git a/package.json b/package.json index e8fe789..5b47ba8 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "scripts": { "cli": "tsx src/cli/main.ts", "lint": "bunx @biomejs/biome ci .", - "test": "vitest run --maxWorkers 1", + "test": "vitest run --maxWorkers 1 --exclude \"ui/**\" && bun run --cwd ui test", "typecheck": "tsc --noEmit", "build-ui": "bun install --cwd ui && bun run --cwd ui build", "sync-ui-dist": "mkdir -p dist/ui-dist && rm -rf dist/ui-dist/assets && cp -R ui/dist/* dist/ui-dist/", diff --git a/ui/bun.lock b/ui/bun.lock index 5750cc4..4c7c97d 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -12,13 +12,37 @@ "devDependencies": { "@socketsecurity/bun-security-scanner": "^1.1.3", "@sveltejs/vite-plugin-svelte": "^7.3.1", + "jsdom": "^27.0.0", "svelte": "^5.57.1", "typescript": "^6.0.3", "vite": "^8.3.1", + "vitest": "^4.1.11", }, }, }, "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.4.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.1", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.4", "", { "dependencies": { "@csstools/color-helpers": "^6.1.2", "@csstools/css-calc": "^3.4.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.1", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.1", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.14", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.2", "", {}, "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="], + + "@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -67,40 +91,98 @@ "@socketsecurity/bun-security-scanner": ["@socketsecurity/bun-security-scanner@1.1.3", "", {}, "sha512-etUBYn5rx/ubI2JaAplX/Dr48X4uufcXVxvcVp68DZFne+ROlUQTcEw7pJFgq6UY4PWbChvhWnoNQBnFWwXyEA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.1", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@visual-json/core": ["@visual-json/core@0.4.0", "", {}, "sha512-T/HvRIp7r6SehxVnO+akV72NLv2OMBEoQdYTFbpwMi6uU2TaXQOpIOHBG4r9XBz7OqC2e8aedOwal/NtqtzyCg=="], "@visual-json/svelte": ["@visual-json/svelte@0.4.0", "", { "dependencies": { "@jsrob/svelte-portal": "^0.2.1", "@visual-json/core": "0.4.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-Qy3aS94H6z62wlmpUM5XjZcrGi/P5vZmzZ8a1YfzE+30ZoB8n8Jo7XwLQFqDdjAfWOnu6xvdTmefjnWLQbY8bw=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="], + + "data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esrap": ["esrap@2.4.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "jsdom": ["jsdom@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], @@ -127,44 +209,114 @@ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + "lucide-svelte": ["lucide-svelte@1.0.1", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg=="], "magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + "svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], + + "tldts": ["tldts@7.4.16", "", { "dependencies": { "tldts-core": "^7.4.16" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg=="], + + "tldts-core": ["tldts-core@7.4.16", "", {}, "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "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" }, "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.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@vitest/mocker/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "@vitest/snapshot/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "is-reference/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "vitest/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "@vitest/mocker/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@vitest/snapshot/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "svelte/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "vitest/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], } } diff --git a/ui/package.json b/ui/package.json index 7f6a270..ddcbc4e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,14 +5,17 @@ "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "devDependencies": { "@socketsecurity/bun-security-scanner": "^1.1.3", "@sveltejs/vite-plugin-svelte": "^7.3.1", "svelte": "^5.57.1", "typescript": "^6.0.3", - "vite": "^8.3.1" + "vite": "^8.3.1", + "jsdom": "^27.0.0", + "vitest": "^4.1.11" }, "dependencies": { "@visual-json/core": "^0.4.0", diff --git a/ui/src/App.svelte b/ui/src/App.svelte index ad2aa14..cdd229a 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -14,7 +14,6 @@ import { Stethoscope, } from "lucide-svelte"; import { onMount } from "svelte"; -import { requestRepoProposal } from "./repo-proposal.js"; type ConfigFormat = "json" | "yaml"; type EditorTab = "visual" | "raw"; @@ -187,17 +186,36 @@ let saving = $state(false); let previewLoading = $state(false); let applying = $state(false); let doctorLoading = $state(false); -let addingRepo = $state(false); + let ghReposLoading = $state(false); let errorMessage = $state(""); let successMessage = $state(""); let previewResult = $state(null); let applyResult = $state(null); +let appliedSnapshot = $state(""); +let previewSnapshot = $state(""); let doctorResult = $state(null); let rawValue = $state(""); let currentTab = $state("visual"); let originalSnapshot = $state(""); +let draftError = $derived.by(() => { + if (format !== "json") + return "YAML editing is not supported. Use the CLI to edit this file."; + try { + JSON.parse(rawValue); + return ""; + } catch (error) { + return error instanceof Error ? error.message : "Invalid JSON"; + } +}); +let draftValid = $derived(format === "json" && !draftError); let unsavedChanges = $derived(serializeCurrentValue() !== originalSnapshot); +// A revision identifies an edit; the epoch identifies a loaded file/baseline. +let draftRevision = 0; +let loadEpoch = 0; +let previewRequest = 0; +let doctorRequest = 0; +let ghRequest = 0; let ghRepos = $state([]); let ghAccounts = $state([]); let ghAvailable = $state(false); @@ -223,20 +241,9 @@ function extractCategories( : []; } -function getCurrentCategories() { - if (currentTab === "raw") { - try { - const parsed = parseRawValue(); - return extractCategories(parsed as JsonValue); - } catch { - return extractCategories(value); - } - } - - return extractCategories(value); -} - -const categories = $derived(getCurrentCategories()); +const categories = $derived( + draftValid ? extractCategories(JSON.parse(rawValue)) : [], +); const filteredGhRepos = $derived.by(() => { const query = ghRepoFilter.trim().toLowerCase(); if (!query) { @@ -260,7 +267,7 @@ onMount(async () => { }); $effect(() => { - if (loading || saving || applying || addingRepo) { + if (loading || saving || applying) { return; } @@ -269,13 +276,7 @@ $effect(() => { return; } - if (currentTab === "raw") { - try { - parseRawValue(); - } catch { - return; - } - } + if (!draftValid) return; if (previewTimer) { clearTimeout(previewTimer); @@ -298,29 +299,70 @@ function serializeConfig(source: JsonValue) { } function serializeCurrentValue() { - if (currentTab === "raw") { - try { - return serializeConfig(parseRawValue() as JsonValue); - } catch { - return rawValue; - } + try { + return serializeConfig(JSON.parse(rawValue)); + } catch { + return rawValue; } - - return serializeConfig(value); } function getCurrentPayload() { - return (currentTab === "raw" ? parseRawValue() : value) as JsonValue; + if (!draftValid) throw new Error(draftError || "JSON editing is unavailable"); + return JSON.parse(rawValue) as JsonValue; } function getConfigObject() { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; + const current = getCurrentPayload(); + if (!current || typeof current !== "object" || Array.isArray(current)) { + throw new Error("Config must be a JSON object"); + } + return current as Record; +} + +function editRaw(text: string) { + rawValue = text; + draftRevision++; + previewRequest++; + previewLoading = false; + previewResult = null; + previewSnapshot = ""; + applyResult = null; + successMessage = ""; + try { + value = JSON.parse(text); + } catch { + /* Preserve invalid text without falling back. */ + } +} + +function editVisual(next: JsonValue) { + value = next; + editRaw(serializeConfig(next)); +} + +function switchTab(tab: EditorTab) { + if (tab === "visual" && !draftValid) return; + currentTab = tab; +} + +async function responsePayload(response: Response) { + const payload = await response.json(); + if (!response.ok) { + throw new Error( + typeof payload.message === "string" + ? payload.message + : `Request failed (${response.status})`, + ); + } + return payload; } function getRepoTemplate() { - const config = getConfigObject(); + const payload = getCurrentPayload(); + const config = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : {}; const defaults = config.defaults && typeof config.defaults === "object" ? (config.defaults as Record) @@ -340,6 +382,7 @@ function getRepoTemplate() { } function getSelectedRepoPreview(): RepoPreview { + if (!draftValid) return null; const selected = ghRepos.find( (repo) => repo.nameWithOwner === ghSelectedRepo, ); @@ -349,148 +392,171 @@ function getSelectedRepoPreview(): RepoPreview { return { ...selected, - category: ghSelectedCategory || getRepoTemplate().category, + category: categories.includes(ghSelectedCategory) + ? ghSelectedCategory + : getRepoTemplate().category, }; } async function load() { + const epoch = ++loadEpoch; + const revision = draftRevision; + previewRequest++; loading = true; errorMessage = ""; - try { - const response = await fetch("/api/config"); - if (!response.ok) { - throw new Error(await response.text()); - } - - const payload = await response.json(); + const payload = await responsePayload(await fetch("/api/config")); + if (epoch !== loadEpoch || revision !== draftRevision) return false; schema = payload.schema ?? null; value = payload.value; configPath = payload.path; format = payload.format; rawValue = payload.raw; - originalSnapshot = serializeConfig(payload.value as JsonValue); + originalSnapshot = + format === "json" ? serializeConfig(payload.value) : payload.raw; + currentTab = format === "json" ? currentTab : "raw"; + draftRevision++; + previewResult = null; + previewSnapshot = ""; + applyResult = null; + return true; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to load config"; + if (epoch === loadEpoch) + errorMessage = + error instanceof Error ? error.message : "failed to load config"; + return false; } finally { - loading = false; + if (epoch === loadEpoch) loading = false; } } async function reload() { + if (unsavedChanges && !window.confirm("Discard unsaved changes and reload?")) + return; successMessage = ""; - await load(); - await loadDoctor(); - await loadGhRepos(); - await previewWorkspace(); + if (!(await load())) return; + await Promise.all([ + loadDoctor(), + loadGhRepos(), + previewWorkspace({ silent: true }), + ]); successMessage = `reloaded ${configPath}`; } async function save() { + if (!draftValid || loading || saving || applying) return; + const payload = getCurrentPayload(); + const snapshot = serializeConfig(payload); + const epoch = loadEpoch; + const revision = draftRevision; saving = true; successMessage = ""; errorMessage = ""; - try { - const payload = currentTab === "raw" ? parseRawValue() : value; - const response = await fetch("/api/config", { - method: "PUT", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(await response.text()); + const result = await responsePayload( + await fetch("/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + // The server saved this snapshot, even if a newer edit is already visible. + if (epoch === loadEpoch) { + originalSnapshot = + result.value === undefined ? snapshot : serializeConfig(result.value); + if (revision === draftRevision) successMessage = `saved ${configPath}`; } - - await load(); - await loadGhRepos(); - await previewWorkspace({ silent: true }); - successMessage = `saved ${configPath}`; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to save config"; + if (epoch === loadEpoch && revision === draftRevision) + errorMessage = + error instanceof Error ? error.message : "failed to save config"; } finally { saving = false; } } async function previewWorkspace(options?: { silent?: boolean }) { + if (!draftValid || loading || saving || applying) return; + const payload = getCurrentPayload(); + const request = ++previewRequest; + const epoch = loadEpoch; + const revision = draftRevision; previewLoading = true; if (!options?.silent) { successMessage = ""; errorMessage = ""; } - try { - const response = await fetch("/api/preview", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(getCurrentPayload()), - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - const payload = await response.json(); - previewResult = payload.result ?? null; - if (!options?.silent) { - successMessage = "workspace plan updated"; - } + const result = await responsePayload( + await fetch("/api/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + if ( + request !== previewRequest || + epoch !== loadEpoch || + revision !== draftRevision + ) + return; + previewResult = result.result ?? null; + previewSnapshot = serializeConfig(payload); + if (!options?.silent) successMessage = "workspace plan updated"; } catch (error) { - if (!options?.silent) { + if ( + request === previewRequest && + epoch === loadEpoch && + revision === draftRevision && + !options?.silent + ) { errorMessage = error instanceof Error ? error.message : "failed to preview workspace"; } } finally { - previewLoading = false; + if (request === previewRequest) previewLoading = false; } } async function applyWorkspace() { + if (!draftValid || loading || saving || applying) return; + const payload = getCurrentPayload(); + const snapshot = serializeConfig(payload); + const epoch = loadEpoch; + const revision = draftRevision; applying = true; + previewRequest++; + previewLoading = false; successMessage = ""; errorMessage = ""; - try { - const response = await fetch("/api/apply", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(getCurrentPayload()), - }); - - if (!response.ok) { - throw new Error(await response.text()); - } - - const payload = await response.json(); - applyResult = (payload.result ?? null) as ApplyResult | null; - await load(); - await loadDoctor(); - await loadGhRepos(); - await previewWorkspace({ silent: true }); - if (applyResult) { - successMessage = `applied workspace / fetched ${applyResult.fetchedRepos.length} / linked ${applyResult.linkedCount} repos / linked ${applyResult.agentSkills.linkedCount} agent skills / copied config`; - } else { - successMessage = payload.message ?? `applied ${configPath}`; - } + const result = await responsePayload( + await fetch("/api/apply", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + ); + if (epoch !== loadEpoch) return; + const acknowledged = + result.value === undefined ? snapshot : serializeConfig(result.value); + originalSnapshot = acknowledged; + appliedSnapshot = acknowledged; + if (revision !== draftRevision) return; + applyResult = result.result ?? null; + successMessage = result.message ?? `applied ${configPath}`; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to apply workspace"; + if (epoch === loadEpoch && revision === draftRevision) + errorMessage = + error instanceof Error ? error.message : "failed to apply workspace"; } finally { applying = false; } } async function loadDoctor() { + const request = ++doctorRequest; + const epoch = loadEpoch; doctorLoading = true; try { @@ -499,34 +565,43 @@ async function loadDoctor() { throw new Error(await response.text()); } const payload = await response.json(); + if (request !== doctorRequest || epoch !== loadEpoch) return; doctorResult = payload.result ?? null; } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to load doctor result"; + if (request === doctorRequest && epoch === loadEpoch) + errorMessage = + error instanceof Error ? error.message : "failed to load doctor result"; } finally { - doctorLoading = false; + if (request === doctorRequest) doctorLoading = false; } } -async function addRepoTemplate() { - addingRepo = true; +function appendRepo(repo: Record, message: string) { + if (!draftValid || loading || saving || applying) return; successMessage = ""; errorMessage = ""; - try { - value = await requestRepoProposal(getCurrentPayload(), undefined); - rawValue = serializeConfig(value); - await previewWorkspace(); - successMessage = "added repo template"; + const config = getConfigObject(); + if (config.repos !== undefined && !Array.isArray(config.repos)) + throw new Error("repos must be an array"); + editVisual({ + ...config, + repos: [...((config.repos as JsonValue[]) ?? []), repo], + } as JsonValue); + successMessage = message; } catch (error) { errorMessage = - error instanceof Error ? error.message : "failed to add repo template"; - } finally { - addingRepo = false; + error instanceof Error ? error.message : "failed to add repo"; } } +function addRepoTemplate() { + appendRepo(getRepoTemplate(), "added repo template to draft"); +} + async function loadGhRepos(ownerOverride?: string) { + const request = ++ghRequest; + const epoch = loadEpoch; ghReposLoading = true; ghSelectedRepo = ""; ghRepoFilter = ""; @@ -540,6 +615,7 @@ async function loadGhRepos(ownerOverride?: string) { } const payload = (await response.json()) as GhReposPayload; + if (request !== ghRequest || epoch !== loadEpoch) return; ghAvailable = payload.available; ghAccounts = payload.accounts ?? []; ghRepos = payload.repositories ?? []; @@ -547,6 +623,7 @@ async function loadGhRepos(ownerOverride?: string) { ghSelectedRepo = ""; ghSelectedCategory = ghSelectedCategory || categories[0] || ""; } catch (error) { + if (request !== ghRequest || epoch !== loadEpoch) return; ghAvailable = false; ghAccounts = []; ghRepos = []; @@ -555,49 +632,22 @@ async function loadGhRepos(ownerOverride?: string) { errorMessage = error instanceof Error ? error.message : "failed to load gh repositories"; } finally { - ghReposLoading = false; + if (request === ghRequest) ghReposLoading = false; } } -async function addSelectedGhRepo() { +function addSelectedGhRepo() { const selected = selectedRepoPreview; - if (!selected) { - return; - } - - addingRepo = true; - successMessage = ""; - errorMessage = ""; - - try { - value = await requestRepoProposal(getCurrentPayload(), { + if (!selected) return; + appendRepo( + { provider: selected.provider, owner: selected.owner, name: selected.name, category: selected.category, - }); - rawValue = serializeConfig(value); - await previewWorkspace(); - successMessage = `added ${selected.nameWithOwner}`; - } catch (error) { - errorMessage = - error instanceof Error ? error.message : "failed to add selected repo"; - } finally { - addingRepo = false; - } -} - -function parseRawValue() { - if (format === "json") { - return JSON.parse(rawValue); - } - - const trimmed = rawValue.trim(); - if (!trimmed) { - return {}; - } - - return JSON.parse(JSON.stringify(value)); + }, + `added ${selected.nameWithOwner} to draft`, + ); } @@ -613,18 +663,22 @@ function parseRawValue() { {format} {unsavedChanges ? 'unsaved' : 'saved'} - - - + {#if draftError && !loading} + + {/if} + {#if errorMessage}

{errorMessage}

{/if} @@ -639,8 +693,8 @@ function parseRawValue() {

{configPath ? configPath.split('/').at(-1) : 'ghq-sector config'}

- - + +
@@ -648,13 +702,11 @@ function parseRawValue() { {#if loading}
Loading config...
{:else if currentTab === 'raw'} - + {:else if schema} { - value = nextValue; - }} + onchange={editVisual} {schema} height="min(78vh, 920px)" class="json-editor" @@ -672,10 +724,10 @@ function parseRawValue() {

Workspace plan

- -
@@ -774,7 +826,7 @@ function parseRawValue() { Doctor {doctorResult?.ok ? 'Healthy' : 'Warn'} - @@ -802,7 +854,7 @@ function parseRawValue() { Repo presets {ghAvailable ? `${ghRepos.length} repos` : 'template only'} - @@ -820,9 +872,9 @@ function parseRawValue() { type="button" class="ghost-button icon-only-button repo-template-button" onclick={addRepoTemplate} - disabled={loading || saving || addingRepo} - aria-label={addingRepo ? 'Adding empty object' : 'Add empty object'} - data-tooltip={addingRepo ? 'Adding empty object' : 'Add empty object'} + disabled={!draftValid || loading || saving || applying} + aria-label={'Add empty object'} + data-tooltip={'Add empty object'} > @@ -904,9 +956,9 @@ function parseRawValue() {
- {:else}

gh is not available. Only adding an empty repo template is enabled.

diff --git a/ui/test/App.test.ts b/ui/test/App.test.ts new file mode 100644 index 0000000..083f97c --- /dev/null +++ b/ui/test/App.test.ts @@ -0,0 +1,496 @@ +import { flushSync, mount, tick, unmount } from "svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import App from "../src/App.svelte"; + +vi.mock("@visual-json/svelte", async () => ({ + JsonEditor: (await import("./JsonEditor.svelte")).default, +})); + +const original = { + categories: ["work"], + defaults: { owner: "original" }, + repos: [], +}; +const preview = { + summary: { totalRepos: 0 }, + repoLinks: [], + resources: [], + codeWorkspace: { enabled: false, folders: [] }, + agentSkills: { selected: [], duplicateGroups: [], warnings: [], summary: {} }, +}; +const ghPayload = { + available: true, + owner: "alice", + accounts: [{ login: "alice", active: true }], + repositories: [ + { + provider: "github.com", + owner: "alice", + name: "new", + nameWithOwner: "alice/new", + url: "https://github.com/alice/new", + isPrivate: false, + }, + ], +}; +let instance: ReturnType; +let serverValue: unknown; +let configFormat: "json" | "yaml"; +let ghAvailable: boolean; +let writes: { url: string; body: unknown }[]; +let putHandler: (() => Promise) | undefined; +let applyHandler: (() => Promise) | undefined; +let previewHandler: (() => Promise) | undefined; +let configHandler: (() => Promise) | undefined; +let doctorHandler: (() => Promise) | undefined; +let configReads: number; + +function response(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} +function deferred() { + let resolve!: (response: Response) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { + promise, + resolve: (body: unknown, status = 200) => + resolve(new Response(JSON.stringify(body), { status })), + }; +} +async function settle() { + for (let i = 0; i < 12; i++) { + await Promise.resolve(); + await tick(); + } +} +function button(label: string) { + const found = Array.from( + document.querySelectorAll("button"), + ).find( + (element) => + element.getAttribute("aria-label") === label || + element.textContent?.trim() === label, + ); + if (!found) throw new Error(`Missing button ${label}`); + return found; +} +async function click(label: string) { + button(label).click(); + await settle(); +} +async function input(label: string, value: string) { + const element = document.querySelector< + HTMLInputElement | HTMLTextAreaElement + >(`[aria-label="${label}"]`); + if (!element) throw new Error(`Missing input ${label}`); + element.value = value; + element.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); +} +function raw() { + return ( + document.querySelector( + '[aria-label="Raw JSON config"]', + ) as HTMLTextAreaElement + ).value; +} +function dirty() { + return document.querySelector(".badges")?.textContent?.includes("unsaved"); +} +async function start() { + instance = mount(App, { target: document.body }); + flushSync(); + await settle(); +} + +beforeEach(() => { + serverValue = structuredClone(original); + configFormat = "json"; + ghAvailable = false; + writes = []; + configReads = 0; + putHandler = undefined; + applyHandler = undefined; + previewHandler = undefined; + configHandler = undefined; + doctorHandler = undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, options?: RequestInit) => { + if (options?.method) { + const body = JSON.parse(options.body as string); + writes.push({ url, body }); + if (url === "/api/config") { + if (putHandler) return putHandler(); + serverValue = body; + return response({ ok: true }); + } + if (url === "/api/apply") { + if (applyHandler) return applyHandler(); + serverValue = body; + return response({ ok: true, message: "workspace applied" }); + } + if (url === "/api/preview") + return previewHandler + ? previewHandler() + : response({ result: preview }); + throw new Error(`Unexpected write ${url}`); + } + if (url === "/api/config") { + configReads++; + return configHandler + ? configHandler() + : response({ + path: "/fixture/config.json", + format: configFormat, + schema: {}, + value: serverValue, + raw: + configFormat === "yaml" + ? "categories: [work]\nrepos: []\n" + : JSON.stringify(serverValue, null, 2), + }); + } + if (url === "/api/doctor") + return doctorHandler ? doctorHandler() : response({ result: null }); + if (url.startsWith("/api/gh/repos")) + return response( + ghAvailable + ? ghPayload + : { available: false, repositories: [], accounts: [] }, + ); + throw new Error(`Unexpected request ${url}`); + }), + ); + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); +}); +afterEach(async () => { + if (instance) await unmount(instance); + document.body.innerHTML = ""; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("JSON draft retention", () => { + it("round trips Visual / Raw without losing either edit", async () => { + await start(); + const visualEdit = { + ...original, + categories: ["visual"], + defaults: { owner: "draft" }, + }; + await input("Visual JSON test editor", JSON.stringify(visualEdit)); + await click("Raw"); + expect(JSON.parse(raw())).toEqual(visualEdit); + const rawEdit = { ...visualEdit, categories: ["raw"] }; + await input("Raw JSON config", JSON.stringify(rawEdit)); + await click("Visual"); + expect( + JSON.parse( + ( + document.querySelector( + '[aria-label="Visual JSON test editor"]', + ) as HTMLTextAreaElement + ).value, + ), + ).toEqual(rawEdit); + await click("Raw"); + expect(JSON.parse(raw())).toEqual(rawEdit); + }); + + it("retains invalid text and blocks visual, save, preview, apply and repo additions", async () => { + await start(); + await click("Raw"); + writes = []; + await input("Raw JSON config", '{"categories": ['); + for (const label of [ + "Visual", + "Save config", + "Refresh workspace plan", + "Apply workspace", + "Add empty object", + ]) + expect(button(label).disabled).toBe(true); + await click("Visual"); + await click("Save config"); + await click("Apply workspace"); + await new Promise((done) => setTimeout(done, 300)); + await settle(); + expect(raw()).toBe('{"categories": ['); + expect(writes).toEqual([]); + expect(dirty()).toBe(true); + }); + + it("adds empty repos only to the current draft, keeping categories/defaults", async () => { + await start(); + await click("Raw"); + const draft = { + ...original, + categories: ["draft"], + defaults: { owner: "edited", category: "draft" }, + }; + await input("Raw JSON config", JSON.stringify(draft)); + await click("Add empty object"); + expect(JSON.parse(raw())).toEqual({ + ...draft, + repos: [ + { + provider: "github.com", + owner: "edited", + name: "", + category: "draft", + }, + ], + }); + expect(serverValue).toEqual(original); + expect( + writes.some( + (write) => write.url === "/api/repos" || write.url === "/api/config", + ), + ).toBe(false); + }); + + it("clears a failed save error when a repo is successfully appended", async () => { + await start(); + await click("Raw"); + putHandler = () => response({ message: "write failed" }, 500); + await click("Save config"); + expect(document.querySelector(".status.error")?.textContent).toBe( + "write failed", + ); + await click("Add empty object"); + expect(document.querySelector(".status.error")).toBeNull(); + expect(document.querySelector(".status.success")?.textContent).toBe( + "added repo template to draft", + ); + expect(JSON.parse(raw()).repos).toHaveLength(1); + expect(serverValue).toEqual(original); + }); + + it("clears the previous operation's success when appending to an invalid config fails", async () => { + await start(); + await click("Raw"); + const pendingDoctor = deferred(); + doctorHandler = () => pendingDoctor.promise; + await click("Reload"); + // The file is loaded while Reload still awaits its diagnostics. A new valid + // JSON draft can have an invalid repos field without being saved to disk. + const draft = { ...original, repos: "invalid repos" }; + await input("Raw JSON config", JSON.stringify(draft)); + pendingDoctor.resolve({ result: null }); + await settle(); + expect(document.querySelector(".status.success")?.textContent).toContain( + "reloaded", + ); + await click("Add empty object"); + expect(document.querySelector(".status.success")).toBeNull(); + expect(document.querySelector(".status.error")?.textContent).toBe( + "repos must be an array", + ); + expect(JSON.parse(raw())).toEqual(draft); + expect(serverValue).toEqual(original); + }); + + it("adds a GitHub preset locally without discarding raw changes", async () => { + ghAvailable = true; + await start(); + await click("Raw"); + const draft = { + ...original, + categories: ["draft"], + defaults: { owner: "edited" }, + }; + await input("Raw JSON config", JSON.stringify(draft)); + const repository = Array.from(document.querySelectorAll("select")).find( + (select) => select.querySelector('option[value="alice/new"]'), + ); + if (!repository) throw new Error("Missing repository select"); + for (const option of repository.options) + option.selected = option.value === "alice/new"; + repository.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + await click("Add selected repo"); + const result = JSON.parse(raw()); + expect(result.categories).toEqual(draft.categories); + expect(result.defaults).toEqual(draft.defaults); + expect(result.repos[0].name).toBe("new"); + expect(result.repos[0].category).toBe("draft"); + expect(serverValue).toEqual(original); + expect( + writes.some( + (write) => write.url === "/api/repos" || write.url === "/api/config", + ), + ).toBe(false); + }); + + it("retains invalid raw after a GitHub repo is selected without rendering the old draft", async () => { + ghAvailable = true; + await start(); + await click("Raw"); + const repository = Array.from(document.querySelectorAll("select")).find( + (select) => select.querySelector('option[value="alice/new"]'), + ); + if (!repository) throw new Error("Missing repository select"); + for (const option of repository.options) + option.selected = option.value === "alice/new"; + repository.dispatchEvent(new Event("change", { bubbles: true })); + await settle(); + await input("Raw JSON config", "{invalid"); + expect(raw()).toBe("{invalid"); + expect(button("Add selected repo").disabled).toBe(true); + await input("Raw JSON config", "null"); + expect(raw()).toBe("null"); + await click("Add selected repo"); + expect(raw()).toBe("null"); + }); + + it("uses the same valid snapshot for preview, save and apply", async () => { + await start(); + await click("Raw"); + writes = []; + const draft = { ...original, defaults: { owner: "shared" } }; + await input("Raw JSON config", JSON.stringify(draft)); + await click("Refresh workspace plan"); + await click("Save config"); + await click("Apply workspace"); + for (const url of ["/api/preview", "/api/config", "/api/apply"]) + expect(writes.find((write) => write.url === url)?.body).toEqual(draft); + expect(dirty()).toBe(false); + expect(configReads).toBe(1); + }); + + it("records the server-normalized saved baseline without rewriting the visible draft", async () => { + await start(); + await click("Raw"); + const draft = { ...original, extra: "keep visible" }; + await input("Raw JSON config", JSON.stringify(draft)); + putHandler = () => response({ ok: true, value: original }); + await click("Save config"); + expect(JSON.parse(raw())).toEqual(draft); + expect(dirty()).toBe(true); + await input("Raw JSON config", JSON.stringify(original)); + expect(dirty()).toBe(false); + }); + + it("keeps baseline and draft on save/apply errors", async () => { + await start(); + await click("Raw"); + const draft = { ...original, categories: ["draft"] }; + await input("Raw JSON config", JSON.stringify(draft)); + putHandler = () => response({ message: "write failed" }, 500); + await click("Save config"); + expect(dirty()).toBe(true); + expect(JSON.parse(raw())).toEqual(draft); + expect(document.body.textContent).toContain("write failed"); + applyHandler = () => + response( + { message: "Apply stopped; completed changes were not rolled back." }, + 500, + ); + await click("Apply workspace"); + expect(dirty()).toBe(true); + expect(JSON.parse(raw())).toEqual(draft); + expect(configReads).toBe(1); + }); + + it("prevents repeated saves and preserves newer edits when a save returns late", async () => { + await start(); + await click("Raw"); + const first = { ...original, categories: ["first"] }; + const second = { ...original, categories: ["second"] }; + await input("Raw JSON config", JSON.stringify(first)); + const pending = deferred(); + putHandler = () => pending.promise; + await click("Save config"); + button("Saving config").click(); + await settle(); + await input("Raw JSON config", JSON.stringify(second)); + pending.resolve({ ok: true }); + await settle(); + expect(JSON.parse(raw())).toEqual(second); + expect(dirty()).toBe(true); + expect(configReads).toBe(1); + expect(writes.filter((write) => write.url === "/api/config")).toHaveLength( + 1, + ); + // The persisted baseline is the acknowledged snapshot, rather than the newer draft. + await input("Raw JSON config", JSON.stringify(first)); + expect(dirty()).toBe(false); + }); + + it("keeps newer edits after delayed apply and ignores stale previews", async () => { + await start(); + await click("Raw"); + const pendingPreview = deferred(); + previewHandler = () => pendingPreview.promise; + await click("Refresh workspace plan"); + const first = { ...original, categories: ["first"] }; + await input("Raw JSON config", JSON.stringify(first)); + const pendingApply = deferred(); + applyHandler = () => pendingApply.promise; + await click("Apply workspace"); + const second = { ...original, categories: ["second"] }; + await input("Raw JSON config", JSON.stringify(second)); + pendingPreview.resolve({ + result: { ...preview, summary: { totalRepos: 999 } }, + }); + pendingApply.resolve({ ok: true, message: "old apply completed" }); + await settle(); + expect(JSON.parse(raw())).toEqual(second); + expect(dirty()).toBe(true); + expect(document.body.textContent).not.toContain("999"); + expect(document.body.textContent).not.toContain("old apply completed"); + }); + + it("only discards on confirmed reload and reports read failures without losing the draft", async () => { + await start(); + await click("Raw"); + const draft = { ...original, categories: ["draft"] }; + await input("Raw JSON config", JSON.stringify(draft)); + vi.mocked(window.confirm).mockReturnValue(false); + await click("Reload"); + expect(configReads).toBe(1); + expect(JSON.parse(raw())).toEqual(draft); + vi.mocked(window.confirm).mockReturnValue(true); + configHandler = () => response({ message: "read failed" }, 500); + await click("Reload"); + expect(JSON.parse(raw())).toEqual(draft); + expect(dirty()).toBe(true); + expect(document.body.textContent).toContain("read failed"); + configHandler = undefined; + await click("Reload"); + expect(JSON.parse(raw())).toEqual(original); + expect(dirty()).toBe(false); + }); + + it("shows YAML read-only and does not offer implicit conversion or writes", async () => { + configFormat = "yaml"; + await start(); + expect(raw()).toContain("categories: [work]"); + expect( + ( + document.querySelector( + '[aria-label="Raw JSON config"]', + ) as HTMLTextAreaElement + ).readOnly, + ).toBe(true); + for (const label of [ + "Visual", + "Save config", + "Apply workspace", + "Add empty object", + ]) + expect(button(label).disabled).toBe(true); + expect(writes).toEqual([]); + }); +}); diff --git a/ui/test/JsonEditor.svelte b/ui/test/JsonEditor.svelte new file mode 100644 index 0000000..b8c0f32 --- /dev/null +++ b/ui/test/JsonEditor.svelte @@ -0,0 +1,4 @@ + + diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 0000000..792f9b3 --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [svelte()], + resolve: { + conditions: ["browser"], + alias: { + "@internal/ui": fileURLToPath( + new URL("./src/visual-json-internal.ts", import.meta.url), + ), + }, + }, + test: { + environment: "jsdom", + include: ["test/**/*.test.ts"], + maxWorkers: 1, + }, +});