diff --git a/babel.config.js b/babel.config.js index 958adb475c..95b338ab41 100644 --- a/babel.config.js +++ b/babel.config.js @@ -22,4 +22,8 @@ module.exports = { }, ], ], + plugins: [ + // Transform import.meta.env to process.env for Jest compatibility + "babel-plugin-transform-vite-meta-env", + ], }; diff --git a/bun.lock b/bun.lock index 723a3fa7e8..f8afde53f8 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mux", @@ -110,6 +109,7 @@ "autoprefixer": "^10.4.21", "babel-jest": "^30.2.0", "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-transform-vite-meta-env": "^1.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -129,6 +129,7 @@ "geist": "^1.5.1", "happy-dom": "^20.0.10", "jest": "^30.1.3", + "jest-environment-jsdom": "^30.2.0", "mermaid": "^11.12.0", "nodemon": "^3.1.10", "playwright": "^1.56.0", @@ -705,6 +706,8 @@ "@jest/environment": ["@jest/environment@30.2.0", "", { "dependencies": { "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", "@types/node": "*", "jest-mock": "30.2.0" } }, "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g=="], + "@jest/environment-jsdom-abstract": ["@jest/environment-jsdom-abstract@30.2.0", "", { "dependencies": { "@jest/environment": "30.2.0", "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", "@types/jsdom": "^21.1.7", "@types/node": "*", "jest-mock": "30.2.0", "jest-util": "30.2.0" }, "peerDependencies": { "canvas": "^3.0.0", "jsdom": "*" }, "optionalPeers": ["canvas"] }, "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ=="], + "@jest/expect": ["@jest/expect@30.2.0", "", { "dependencies": { "expect": "30.2.0", "jest-snapshot": "30.2.0" } }, "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA=="], "@jest/expect-utils": ["@jest/expect-utils@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA=="], @@ -1613,6 +1616,8 @@ "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + "babel-plugin-transform-vite-meta-env": ["babel-plugin-transform-vite-meta-env@1.0.3", "", { "dependencies": { "@babel/runtime": "^7.13.9", "@types/babel__core": "^7.1.12" } }, "sha512-eyfuDEXrMu667TQpmctHeTlJrZA6jXYHyEJFjcM0yEa60LS/LXlOg2PBbMb8DVS+V9CnTj/j9itdlDVMcY2zEg=="], + "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], "babel-preset-jest": ["babel-preset-jest@30.2.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.2.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ=="], @@ -2499,6 +2504,8 @@ "jest-each": ["jest-each@30.2.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.2.0", "chalk": "^4.1.2", "jest-util": "30.2.0", "pretty-format": "30.2.0" } }, "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ=="], + "jest-environment-jsdom": ["jest-environment-jsdom@30.2.0", "", { "dependencies": { "@jest/environment": "30.2.0", "@jest/environment-jsdom-abstract": "30.2.0", "@types/jsdom": "^21.1.7", "@types/node": "*", "jsdom": "^26.1.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ=="], + "jest-environment-node": ["jest-environment-node@30.2.0", "", { "dependencies": { "@jest/environment": "30.2.0", "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", "@types/node": "*", "jest-mock": "30.2.0", "jest-util": "30.2.0", "jest-validate": "30.2.0" } }, "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA=="], "jest-haste-map": ["jest-haste-map@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.2.0", "jest-worker": "30.2.0", "micromatch": "^4.0.8", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw=="], @@ -2901,6 +2908,8 @@ "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], + "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "nyc": ["nyc@15.1.0", "", { "dependencies": { "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", "caching-transform": "^4.0.0", "convert-source-map": "^1.7.0", "decamelize": "^1.2.0", "find-cache-dir": "^3.2.0", "find-up": "^4.1.0", "foreground-child": "^2.0.0", "get-package-type": "^0.1.0", "glob": "^7.1.6", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-hook": "^3.0.0", "istanbul-lib-instrument": "^4.0.0", "istanbul-lib-processinfo": "^2.0.2", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.0.2", "make-dir": "^3.0.0", "node-preload": "^0.2.1", "p-map": "^3.0.0", "process-on-spawn": "^1.0.0", "resolve-from": "^5.0.0", "rimraf": "^3.0.0", "signal-exit": "^3.0.2", "spawn-wrap": "^2.0.0", "test-exclude": "^6.0.0", "yargs": "^15.0.2" }, "bin": { "nyc": "bin/nyc.js" } }, "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -3211,6 +3220,8 @@ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], @@ -3721,6 +3732,10 @@ "@jest/core/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "@jest/environment-jsdom-abstract/@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], + + "@jest/environment-jsdom-abstract/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + "@jest/reporters/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/reporters/istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], @@ -4009,6 +4024,12 @@ "jest-each/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "jest-environment-jsdom/@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], + + "jest-environment-jsdom/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], + + "jest-environment-jsdom/jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + "jest-environment-node/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -4343,6 +4364,18 @@ "jest-each/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "jest-environment-jsdom/jsdom/cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "jest-environment-jsdom/jsdom/data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "jest-environment-jsdom/jsdom/tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "jest-environment-jsdom/jsdom/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "jest-environment-jsdom/jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "jest-environment-jsdom/jsdom/whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4457,6 +4490,12 @@ "electron-rebuild/node-gyp/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jest-environment-jsdom/jsdom/cssstyle/@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "jest-environment-jsdom/jsdom/tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "jest-environment-jsdom/jsdom/whatwg-url/tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "nyc/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "nyc/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], @@ -4487,6 +4526,10 @@ "electron-rebuild/node-gyp/make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "jest-environment-jsdom/jsdom/cssstyle/@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "jest-environment-jsdom/jsdom/tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "nyc/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/jest.config.js b/jest.config.js index f831bd2763..d9387e565c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,16 +1,12 @@ -module.exports = { - testEnvironment: "node", - testMatch: ["/src/**/*.test.ts", "/tests/**/*.test.ts"], - collectCoverageFrom: [ - "src/**/*.ts", - "!src/**/*.d.ts", - "!src/desktop/preload.ts", - "!src/browser/api.ts", - "!src/cli/**/*", - "!src/desktop/main.ts", - ], - setupFilesAfterEnv: ["/tests/setup.ts"], +// Shared config for all test projects +const sharedConfig = { moduleNameMapper: { + "\\.(css|less|scss|sass)$": "/tests/__mocks__/styleMock.js", + "^@/version$": "/tests/__mocks__/version.js", + // Mock SVG imports with ?react query (vite-plugin-svgr) + "\\.svg\\?react$": "/tests/__mocks__/svgMock.js", + // Mock modules that use import.meta.url (Workers) + ".*/highlightWorkerClient$": "/tests/__mocks__/highlightWorkerClient.js", "^@/(.*)$": "/src/$1", "^chalk$": "/tests/__mocks__/chalk.js", "^jsdom$": "/tests/__mocks__/jsdom.js", @@ -19,11 +15,65 @@ module.exports = { "^.+\\.(ts|tsx|js|mjs)$": ["babel-jest"], }, // Transform ESM modules to CommonJS for Jest - transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki|json-schema-typed|rou3)/)"], + // The markdown ecosystem has hundreds of ESM-only packages, so we transform + // everything except known-safe CJS packages + transformIgnorePatterns: [ + // Transform everything in node_modules (empty pattern = transform all) + // This is necessary because the unified/remark/rehype ecosystem has + // 100+ ESM-only packages that would each need to be listed + "^$", + ], +}; + +module.exports = { + projects: [ + // Node environment tests (default - existing tests) + { + ...sharedConfig, + displayName: "node", + testEnvironment: "node", + testMatch: [ + "/src/**/*.test.ts", + "/tests/ipc/**/*.test.ts", + "/tests/runtime/**/*.test.ts", + "/tests/*.test.ts", + ], + setupFilesAfterEnv: ["/tests/setup.ts"], + }, + // Browser backend integration tests (node environment, real oRPC) + { + ...sharedConfig, + displayName: "browser", + testEnvironment: "node", + testMatch: ["/tests/browser/**/*.test.ts"], + setupFilesAfterEnv: ["/tests/setup.ts"], + }, + // Browser UI tests (jsdom environment) + { + ...sharedConfig, + displayName: "browser-ui", + testEnvironment: "jsdom", + testMatch: ["/tests/browser/**/*.test.tsx"], + setupFiles: ["/tests/browser/harness/global-setup.js"], + setupFilesAfterEnv: [ + "/tests/setup.ts", + "/tests/browser/harness/jestSetup.ts", + ], + }, + ], + collectCoverageFrom: [ + "src/**/*.ts", + "!src/**/*.d.ts", + "!src/desktop/preload.ts", + "!src/browser/api.ts", + "!src/cli/**/*", + "!src/desktop/main.ts", + ], + // Jest + jsdom integration tests can leave behind non-critical handles. + // Force-exit to keep CI stable and prevent hanging. + forceExit: true, // Run tests in parallel (use 50% of available cores, or 4 minimum) maxWorkers: "50%", - // Force exit after tests complete to avoid hanging on lingering handles - forceExit: true, // 10 minute timeout for integration tests, 10s for unit tests testTimeout: process.env.TEST_INTEGRATION === "1" ? 600000 : 10000, // Detect open handles in development (disabled by default for speed) diff --git a/package.json b/package.json index 7ddf6ac724..3b2004afe6 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "autoprefixer": "^10.4.21", "babel-jest": "^30.2.0", "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-transform-vite-meta-env": "^1.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -169,6 +170,7 @@ "geist": "^1.5.1", "happy-dom": "^20.0.10", "jest": "^30.1.3", + "jest-environment-jsdom": "^30.2.0", "mermaid": "^11.12.0", "nodemon": "^3.1.10", "playwright": "^1.56.0", diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 452e4b8abc..5c2c352e34 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -22,8 +22,35 @@ import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { getBashPath } from "@/node/utils/main/bashPath"; import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; import { DisposableProcess } from "@/node/utils/disposableExec"; -import { expandTilde } from "./tildeExpansion"; import { getInitHookPath, createLineBufferedLoggers } from "./initHook"; +import { expandTilde } from "./tildeExpansion"; + +let cachedNicePath: string | null | undefined; + +async function resolveNicePath(): Promise { + if (cachedNicePath !== undefined) { + return cachedNicePath; + } + + try { + await fsPromises.access("/usr/bin/nice"); + cachedNicePath = "/usr/bin/nice"; + return cachedNicePath; + } catch { + // continue + } + + try { + await fsPromises.access("/bin/nice"); + cachedNicePath = "/bin/nice"; + return cachedNicePath; + } catch { + // continue + } + + cachedNicePath = null; + return cachedNicePath; +} /** * Abstract base class for local runtimes (both WorktreeRuntime and LocalRuntime). @@ -63,14 +90,19 @@ export abstract class LocalBaseRuntime implements Runtime { ); } - // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues - // Windows doesn't have nice command, so just spawn bash directly + // If niceness is specified on Unix/Linux, try to spawn `nice` directly to avoid + // escaping issues. Some minimal environments may not have `nice` on PATH, so + // fall back to running bash directly. const isWindows = process.platform === "win32"; const bashPath = getBashPath(); - const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath; + + const shouldNice = options.niceness !== undefined && !isWindows; + const nicePath = shouldNice ? await resolveNicePath() : null; + + const spawnCommand = nicePath ?? bashPath; const spawnArgs = - options.niceness !== undefined && !isWindows - ? ["-n", options.niceness.toString(), bashPath, "-c", command] + nicePath !== null + ? ["-n", options.niceness!.toString(), bashPath, "-c", command] : ["-c", command]; const childProcess = spawn(spawnCommand, spawnArgs, { @@ -145,7 +177,12 @@ export abstract class LocalBaseRuntime implements Runtime { }); }); - const duration = exitCode.then(() => performance.now() - startTime); + // Always resolve duration even if exitCode rejects (e.g. spawn errors). + // Consumers frequently ignore duration; rejecting would surface as an unhandled rejection. + const duration = exitCode.then( + () => performance.now() - startTime, + () => performance.now() - startTime + ); // Register process group cleanup with DisposableProcess // This ensures ALL background children are killed when process exits @@ -175,8 +212,13 @@ export abstract class LocalBaseRuntime implements Runtime { disposable[Symbol.dispose](); // Kill process and run cleanup }, options.timeout * 1000); - // Clear timeout if process exits naturally - void exitCode.finally(() => clearTimeout(timeoutHandle)); + // Clear timeout if process exits naturally. + // Swallow rejections to avoid unhandled promise noise on spawn errors. + exitCode + .finally(() => clearTimeout(timeoutHandle)) + .catch(() => { + /* ignore */ + }); } return { stdout, stderr, stdin, exitCode, duration }; diff --git a/src/node/services/ExtensionMetadataService.ts b/src/node/services/ExtensionMetadataService.ts index 39b872b07b..a339535ba8 100644 --- a/src/node/services/ExtensionMetadataService.ts +++ b/src/node/services/ExtensionMetadataService.ts @@ -91,6 +91,13 @@ export class ExtensionMetadataService { const content = JSON.stringify(data, null, 2); await writeFileAtomic(this.filePath, content, "utf-8"); } catch (error) { + const err = error as NodeJS.ErrnoException; + // In Jest/browser harness runs we frequently use a temporary config dir that can be + // deleted as soon as a test finishes. Ignore ENOENT to avoid noisy "Cannot log after + // tests are done" warnings. + if (process.env.NODE_ENV === "test" && err.code === "ENOENT") { + return; + } log.error("Failed to save metadata:", error); } } diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 57dffc0fbc..e5c2d48767 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -169,6 +169,12 @@ export class AgentSession { if (this.disposed) { return; } + // Detach listeners first so stopStream() can't emit events into a disposed session. + for (const { event, handler } of this.aiListeners) { + this.aiService.off(event, handler as never); + } + this.aiListeners.length = 0; + this.disposed = true; // Stop any active stream (fire and forget - disposal shouldn't block) @@ -176,10 +182,6 @@ export class AgentSession { // Terminate background processes for this workspace void this.backgroundProcessManager.cleanup(this.workspaceId); - for (const { event, handler } of this.aiListeners) { - this.aiService.off(event, handler as never); - } - this.aiListeners.length = 0; for (const { event, handler } of this.initListeners) { this.initStateManager.off(event, handler as never); } diff --git a/tests/__mocks__/highlightWorkerClient.js b/tests/__mocks__/highlightWorkerClient.js new file mode 100644 index 0000000000..e506bcc08c --- /dev/null +++ b/tests/__mocks__/highlightWorkerClient.js @@ -0,0 +1,7 @@ +// Mock the highlight worker for Jest tests +module.exports = { + highlightCode: async (code, lang) => { + // Return unhighlighted code as fallback + return `
${code}
`; + }, +}; diff --git a/tests/__mocks__/styleMock.js b/tests/__mocks__/styleMock.js new file mode 100644 index 0000000000..79767aa081 --- /dev/null +++ b/tests/__mocks__/styleMock.js @@ -0,0 +1,2 @@ +// Mock CSS imports for Jest +module.exports = {}; diff --git a/tests/__mocks__/svgMock.js b/tests/__mocks__/svgMock.js new file mode 100644 index 0000000000..f53f206abb --- /dev/null +++ b/tests/__mocks__/svgMock.js @@ -0,0 +1,13 @@ +// Mock SVG imports with ?react query string (vite-plugin-svgr style) +// Returns a simple React component that renders an empty span +const React = require("react"); + +const SvgMock = React.forwardRef((props, ref) => + React.createElement("span", { ...props, ref, "data-testid": "svg-mock" }) +); + +SvgMock.displayName = "SvgMock"; + +module.exports = SvgMock; +module.exports.default = SvgMock; +module.exports.ReactComponent = SvgMock; diff --git a/tests/__mocks__/version.js b/tests/__mocks__/version.js new file mode 100644 index 0000000000..958b65e567 --- /dev/null +++ b/tests/__mocks__/version.js @@ -0,0 +1,4 @@ +// Mock version for tests +module.exports = { + VERSION: "0.0.0-test", +}; diff --git a/tests/browser/app/appLoader.test.tsx b/tests/browser/app/appLoader.test.tsx new file mode 100644 index 0000000000..dae204da29 --- /dev/null +++ b/tests/browser/app/appLoader.test.tsx @@ -0,0 +1,99 @@ +/** + * React DOM integration tests for AppLoader. + * + * These tests verify the initial app loading behavior and basic interactions. + * All interactions go through the UI via helpers. + */ + +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { shouldRunIntegrationTests } from "../../testUtils"; +import { + renderWithBackend, + createTempGitRepo, + cleanupTempGitRepo, + waitForAppLoad, + addProjectViaUI, + getProjectName, +} from "../harness"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("AppLoader React Integration", () => { + describe("Initial Render", () => { + test("shows loading screen initially then transitions to app", async () => { + const { cleanup, getByText, ...queries } = await renderWithBackend(); + try { + // Should show loading screen while fetching initial data + expect(getByText(/loading workspaces/i)).toBeInTheDocument(); + + // Wait for loading screen to disappear (app loaded from real backend) + await waitForAppLoad(queries); + } finally { + await cleanup(); + } + }); + + test("shows empty state when no projects exist", async () => { + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // With no projects, should show "No projects" text + const emptyStateText = await queries.findByText("No projects"); + expect(emptyStateText).toBeInTheDocument(); + + // And an "Add Project" button + const addButton = await queries.findByText("Add Project"); + expect(addButton).toBeInTheDocument(); + } finally { + await cleanup(); + } + }); + }); + + describe("Project and Workspace Display", () => { + test("project added via UI appears in sidebar", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add a project via the UI + await addProjectViaUI(user, queries, gitRepo); + + // The project should appear in the sidebar + const projectName = getProjectName(gitRepo); + const projectElement = await queries.findByRole("button", { + name: `Expand project ${projectName}`, + }); + expect(projectElement).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + + }); + + describe("User Interactions", () => { + test("clicking Add Project button opens modal", async () => { + const user = userEvent.setup(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Click the Add Project button + const addButton = await queries.findByText("Add Project"); + await user.click(addButton); + + // Modal should appear + const modal = await queries.findByRole("dialog"); + expect(modal).toBeInTheDocument(); + } finally { + await cleanup(); + } + }); + }); +}); diff --git a/tests/browser/harness/env.ts b/tests/browser/harness/env.ts new file mode 100644 index 0000000000..db053355fd --- /dev/null +++ b/tests/browser/harness/env.ts @@ -0,0 +1,176 @@ +/** + * Browser integration test setup. + * + * Creates a real backend environment (ServiceContainer + oRPC) for testing + * React components with full app state. Reuses patterns from tests/ipc/setup.ts. + */ + +import * as os from "os"; +import * as path from "path"; +import * as fs from "fs/promises"; +import { exec } from "child_process"; +import { promisify } from "util"; +import type { BrowserWindow, WebContents } from "electron"; +import { Config } from "@/node/config"; +import { ServiceContainer } from "@/node/services/serviceContainer"; +import type { ORPCContext } from "@/node/orpc/context"; +import { createOrpcTestClient, type OrpcTestClient } from "../../ipc/orpcTestClient"; +import type { APIClient } from "@/browser/contexts/API"; + +const execAsync = promisify(exec); + +export interface BrowserTestEnv { + /** Real oRPC client (cast to APIClient for React components) */ + api: APIClient; + /** Direct oRPC client for backend assertions */ + orpc: OrpcTestClient; + /** Temp config directory */ + tempDir: string; + /** ServiceContainer for direct access if needed */ + services: ServiceContainer; + /** Cleanup function - call in afterEach */ + cleanup: () => Promise; +} + +/** + * Create a mock BrowserWindow for tests. + * Events are consumed via ORPC subscriptions, not windowService.send(). + */ +function createMockBrowserWindow(): BrowserWindow { + return { + webContents: { + send: jest.fn(), + openDevTools: jest.fn(), + } as unknown as WebContents, + isDestroyed: jest.fn(() => false), + isMinimized: jest.fn(() => false), + restore: jest.fn(), + focus: jest.fn(), + loadURL: jest.fn(), + on: jest.fn(), + setTitle: jest.fn(), + } as unknown as BrowserWindow; +} + +/** + * Create a browser test environment with real backend. + * + * Usage: + * ```ts + * let env: BrowserTestEnv; + * beforeEach(async () => { env = await createBrowserTestEnv(); }); + * afterEach(async () => { await env.cleanup(); }); + * ``` + */ +export async function createBrowserTestEnv(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-browser-test-")); + + const config = new Config(tempDir); + const mockWindow = createMockBrowserWindow(); + + // Prevent browser UI tests from making real network calls for AI. + // IMPORTANT: only set this while constructing ServiceContainer so it cannot leak + // into other test files running in the same Jest worker. + const previousMockAI = process.env.MUX_MOCK_AI; + process.env.MUX_MOCK_AI = "1"; + let services: ServiceContainer; + try { + services = new ServiceContainer(config); + } finally { + if (previousMockAI === undefined) { + delete process.env.MUX_MOCK_AI; + } else { + process.env.MUX_MOCK_AI = previousMockAI; + } + } + + await services.initialize(); + services.windowService.setMainWindow(mockWindow); + + const orpcContext: ORPCContext = { + config: services.config, + aiService: services.aiService, + projectService: services.projectService, + workspaceService: services.workspaceService, + providerService: services.providerService, + terminalService: services.terminalService, + editorService: services.editorService, + windowService: services.windowService, + updateService: services.updateService, + tokenizerService: services.tokenizerService, + serverService: services.serverService, + mcpConfigService: services.mcpConfigService, + mcpServerManager: services.mcpServerManager, + menuEventService: services.menuEventService, + voiceService: services.voiceService, + telemetryService: services.telemetryService, + sessionUsageService: services.sessionUsageService, + }; + + const orpc = createOrpcTestClient(orpcContext); + + // Cast OrpcTestClient to APIClient - they have compatible interfaces + // since OrpcTestClient is RouterClient and APIClient is the same + const api = orpc as unknown as APIClient; + + const cleanup = async () => { + try { + await services.shutdown(); + } finally { + await services.dispose(); + } + + const maxRetries = 3; + + for (let i = 0; i < maxRetries; i++) { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + return; + } catch { + if (i < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (i + 1))); + } + } + } + }; + + return { api, orpc, tempDir, services, cleanup }; +} + +/** + * Create a temporary git repository for testing. + * Returns the path to the repo. + */ +export async function createTempGitRepo(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-repo-")); + + // Initialize with main as the default branch name for consistency + await execAsync("git init -b main", { cwd: tempDir }); + await execAsync( + 'git config user.email "test@example.com" && git config user.name "Test User" && git config commit.gpgsign false', + { cwd: tempDir } + ); + await execAsync( + 'echo "test" > README.md && git add . && git commit -m "Initial commit" && git branch test-branch', + { cwd: tempDir } + ); + + return tempDir; +} + +/** + * Cleanup a temporary git repository. + */ +export async function cleanupTempGitRepo(repoPath: string): Promise { + const maxRetries = 3; + for (let i = 0; i < maxRetries; i++) { + try { + await fs.rm(repoPath, { recursive: true, force: true }); + return; + } catch { + if (i < maxRetries - 1) { + await new Promise((r) => setTimeout(r, 100 * (i + 1))); + } + } + } +} diff --git a/tests/browser/harness/global-setup.js b/tests/browser/harness/global-setup.js new file mode 100644 index 0000000000..d5b1fc9d96 --- /dev/null +++ b/tests/browser/harness/global-setup.js @@ -0,0 +1,127 @@ +/** + * Global setup for jsdom environment - runs before ANY imports. + * Must be JS (not TS) to avoid transpilation issues. + */ + +// Mock import.meta.env for Vite compatibility +// This needs to be set before any imports that use it + +// Polyfill ResizeObserver for Radix/layout components in jsdom +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} +// Default to a desktop viewport so mobile-only behavior (like auto-collapsing the sidebar) +// doesn't interfere with UI integration tests. +try { + Object.defineProperty(globalThis, "innerWidth", { value: 1024, writable: true }); + Object.defineProperty(globalThis, "innerHeight", { value: 768, writable: true }); +} catch { + // ignore +} + +globalThis.import_meta_env = { + VITE_BACKEND_URL: undefined, + MODE: "test", + DEV: false, + PROD: false, +}; + +// Use Node's timers implementation so the returned handles support unref/ref +// requestIdleCallback is used by the renderer for stream batching. +// jsdom doesn't provide it. +globalThis.requestIdleCallback = + globalThis.requestIdleCallback ?? + ((cb) => + globalThis.setTimeout(() => + cb({ didTimeout: false, timeRemaining: () => 50 }) + )); +globalThis.cancelIdleCallback = + globalThis.cancelIdleCallback ?? ((id) => globalThis.clearTimeout(id)); + +// (required by undici timers). This also provides setImmediate/clearImmediate. +const nodeTimers = require("node:timers"); +globalThis.setTimeout = nodeTimers.setTimeout; +globalThis.clearTimeout = nodeTimers.clearTimeout; +globalThis.setInterval = nodeTimers.setInterval; +globalThis.clearInterval = nodeTimers.clearInterval; +globalThis.setImmediate = nodeTimers.setImmediate; +globalThis.clearImmediate = nodeTimers.clearImmediate; + +// Polyfill TextEncoder/TextDecoder - required by undici +const { TextEncoder, TextDecoder } = require("util"); +globalThis.TextEncoder = globalThis.TextEncoder ?? TextEncoder; +globalThis.TextDecoder = globalThis.TextDecoder ?? TextDecoder; + +// Polyfill streams - required by AI SDK +const { + TransformStream, + ReadableStream, + WritableStream, + TextDecoderStream, +} = require("node:stream/web"); +globalThis.TransformStream = globalThis.TransformStream ?? TransformStream; +globalThis.ReadableStream = globalThis.ReadableStream ?? ReadableStream; +globalThis.WritableStream = globalThis.WritableStream ?? WritableStream; +globalThis.TextDecoderStream = globalThis.TextDecoderStream ?? TextDecoderStream; + +// Polyfill MessageChannel/MessagePort - required by undici +const { MessageChannel, MessagePort } = require("node:worker_threads"); +globalThis.MessageChannel = globalThis.MessageChannel ?? MessageChannel; + +// Radix UI (Select, etc.) relies on Pointer Events + pointer capture. +// jsdom doesn't implement these, so provide minimal no-op shims. +if (globalThis.Element && !globalThis.Element.prototype.hasPointerCapture) { + globalThis.Element.prototype.hasPointerCapture = () => false; +} +if (globalThis.Element && !globalThis.Element.prototype.setPointerCapture) { + globalThis.Element.prototype.setPointerCapture = () => {}; +} +if (globalThis.Element && !globalThis.Element.prototype.scrollIntoView) { + globalThis.Element.prototype.scrollIntoView = () => {}; +} +if (globalThis.Element && !globalThis.Element.prototype.releasePointerCapture) { + globalThis.Element.prototype.releasePointerCapture = () => {}; +} +globalThis.MessagePort = globalThis.MessagePort ?? MessagePort; + +// undici reads `performance.markResourceTiming` at import time. In jsdom, +// Some renderer code uses `performance.mark()` for lightweight timing. +if (globalThis.performance && typeof globalThis.performance.mark !== "function") { + globalThis.performance.mark = () => {}; +} +if (globalThis.performance && typeof globalThis.performance.measure !== "function") { + globalThis.performance.measure = () => {}; +} +if ( + globalThis.performance && + typeof globalThis.performance.clearMarks !== "function" +) { + globalThis.performance.clearMarks = () => {}; +} +if ( + globalThis.performance && + typeof globalThis.performance.clearMeasures !== "function" +) { + globalThis.performance.clearMeasures = () => {}; +} + +// `performance` exists but doesn't implement the Resource Timing API. +if ( + globalThis.performance && + typeof globalThis.performance.markResourceTiming !== "function" +) { + globalThis.performance.markResourceTiming = () => {}; +} + +// Now undici can be safely imported +const { fetch, Request, Response, Headers, FormData, Blob } = require("undici"); +globalThis.fetch = globalThis.fetch ?? fetch; +globalThis.Request = globalThis.Request ?? Request; +globalThis.Response = globalThis.Response ?? Response; +globalThis.Headers = globalThis.Headers ?? Headers; +globalThis.FormData = globalThis.FormData ?? FormData; +globalThis.Blob = globalThis.Blob ?? Blob; diff --git a/tests/browser/harness/index.ts b/tests/browser/harness/index.ts new file mode 100644 index 0000000000..40db6cca11 --- /dev/null +++ b/tests/browser/harness/index.ts @@ -0,0 +1,10 @@ +/** + * Browser UI integration test harness. + * + * Keep all non-test helpers in this folder so the top-level `tests/browser/` + * contains only test entrypoints. + */ + +export * from "./env"; +export * from "./render"; +export * from "./ui"; diff --git a/tests/browser/harness/jestSetup.ts b/tests/browser/harness/jestSetup.ts new file mode 100644 index 0000000000..c61964b698 --- /dev/null +++ b/tests/browser/harness/jestSetup.ts @@ -0,0 +1,55 @@ +/** + * Jest setup for jsdom-based browser UI tests. + * + * These tests mount the full app, which schedules a bunch of background React + * updates (Radix tooltips/selects, async effects like branch loading, etc.). + * In jsdom this can produce extremely noisy act(...) warnings that drown out + * real failures. + */ + +import { EventEmitter } from "events"; + +const originalConsoleError = console.error.bind(console); +const originalDefaultMaxListeners = EventEmitter.defaultMaxListeners; +const originalConsoleLog = console.log.bind(console); + +const shouldSuppressConsoleError = (args: unknown[]) => { + const text = args.map(String).join(" ").toLowerCase(); + return ( + text.includes("not wrapped in act") || + text.includes("mock scenario turn mismatch") || + (text.includes("failed to save metadata") && text.includes("extensionmetadata.json")) || + (text.includes("failed to remove project") && + text.includes("cannot remove project with active workspaces")) + ); +}; + +beforeAll(() => { + // The full app creates a bunch of subscriptions on a single EventEmitter + // (ProviderService configChanged). That's OK for these tests, but Node warns + // once the default (10) listener limit is exceeded. + EventEmitter.defaultMaxListeners = 50; + jest.spyOn(console, "error").mockImplementation((...args) => { + if (shouldSuppressConsoleError(args)) { + return; + } + originalConsoleError(...args); + }); + + // Keep the test output focused; individual tests can temporarily unmock if + // they need to assert on logs. + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "debug").mockImplementation(() => {}); +}); + +afterAll(() => { + EventEmitter.defaultMaxListeners = originalDefaultMaxListeners; + (console.error as jest.Mock).mockRestore(); + (console.log as jest.Mock).mockRestore(); + (console.warn as jest.Mock).mockRestore(); + (console.debug as jest.Mock).mockRestore(); + + // Ensure captured originals don't get tree-shaken / flagged as unused in some tooling. + void originalConsoleLog; +}); diff --git a/tests/browser/harness/render.tsx b/tests/browser/harness/render.tsx new file mode 100644 index 0000000000..4d648a244f --- /dev/null +++ b/tests/browser/harness/render.tsx @@ -0,0 +1,118 @@ +/** + * React DOM integration test utilities. + * + * Provides renderWithBackend() - renders components with a real oRPC backend + * using @testing-library/react. Components get full React context and can + * make real API calls that hit the ServiceContainer. + */ + +import React from "react"; +import { act, render, type RenderOptions, type RenderResult } from "@testing-library/react"; +import { AppLoader } from "@/browser/components/AppLoader"; +import type { APIClient } from "@/browser/contexts/API"; +import { createBrowserTestEnv, type BrowserTestEnv } from "./env"; + +export interface RenderWithBackendResult extends RenderResult { + /** Test environment with real backend */ + env: BrowserTestEnv; + /** Cleanup function - unmounts component and cleans up backend */ + cleanup: () => Promise; +} + +export interface RenderWithBackendOptions extends Omit { + /** Pass an existing test environment instead of creating a new one */ + env?: BrowserTestEnv; +} + +/** + * Render a component wrapped in the full app context with a real backend. + * + * Usage: + * ```tsx + * const { env, cleanup, getByText } = await renderWithBackend(); + * // Interact with DOM using @testing-library queries + * expect(getByText(/loading/i)).toBeInTheDocument(); + * await cleanup(); + * ``` + * + * With pre-existing env (for setup before render): + * ```tsx + * const env = await createBrowserTestEnv(); + * const { cleanup, getByText } = await renderWithBackend({ env }); + * ``` + * + * Note: tests should prefer UI-driven setup. `env.orpc` exists for rare cases where + * the UI cannot reasonably reach a state. + */ +export async function renderWithBackend( + options?: RenderWithBackendOptions +): Promise { + const existingEnv = options?.env; + const env = existingEnv ?? (await createBrowserTestEnv()); + + // Cast oRPC client to APIClient for React components + const client = env.orpc as unknown as APIClient; + + const renderResult = render(, { + ...options, + }); + + const cleanup = async () => { + await act(async () => { + renderResult.unmount(); + }); + + // Only cleanup the env if we created it (not passed in) + if (!existingEnv) { + await env.cleanup(); + } + }; + + return { + ...renderResult, + env, + cleanup, + }; +} + +/** + * Render a custom component tree with a real backend. + * + * Use this when you want to test a specific component rather than the full app. + * + * Usage: + * ```tsx + * const { env, cleanup, getByRole } = await renderComponentWithBackend( + * + * ); + * ``` + */ +export async function renderComponentWithBackend( + ui: React.ReactElement, + options?: Omit +): Promise { + const env = await createBrowserTestEnv(); + + // Cast oRPC client to APIClient for React components + const client = env.orpc as unknown as APIClient; + + // Wrap UI in APIProvider for context + const { APIProvider } = await import("@/browser/contexts/API"); + + const renderResult = render({ui}, { + ...options, + }); + + const cleanup = async () => { + await act(async () => { + renderResult.unmount(); + }); + await env.cleanup(); + }; + + return { + ...renderResult, + env, + cleanup, + }; +} diff --git a/tests/browser/harness/ui.tsx b/tests/browser/harness/ui.tsx new file mode 100644 index 0000000000..1d4e6b3f4a --- /dev/null +++ b/tests/browser/harness/ui.tsx @@ -0,0 +1,521 @@ +/** + * UI helper functions for integration tests. + * + * These helpers encapsulate common user flows, performing actions through + * the DOM just like a real user would. They use @testing-library queries + * and userEvent for realistic interactions. + * + * Use these instead of direct oRPC calls to keep tests UI-driven. + */ + +import { waitFor, within, type RenderResult } from "@testing-library/react"; +import type { UserEvent } from "@testing-library/user-event"; + +type Queries = Pick< + RenderResult, + | "findByRole" + | "findByText" + | "findByTestId" + | "findByPlaceholderText" + | "queryByPlaceholderText" + | "queryByRole" + | "queryByText" + | "getByRole" +>; + +function escapeForRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Wait for the app to finish loading (loading screen disappears). + */ +export async function waitForAppLoad(queries: Pick) { + await waitFor( + () => { + expect(queries.queryByText(/loading workspaces/i)).toBeNull(); + }, + { timeout: 5000 } + ); +} + +/** + * Open the Settings modal. + * + * Note: the Dialog is rendered in a portal; queries must search `document.body`. + * RTL's render() uses `document.body` as baseElement by default, so RenderResult + * queries work fine. + */ +export async function openSettingsModal( + user: UserEvent, + queries: Pick +): Promise { + const settingsButton = await queries.findByTestId("settings-button"); + await user.click(settingsButton); + + const modal = await queries.findByRole("dialog"); + expect(modal).toBeTruthy(); + return modal; +} + +/** + * Open the Settings modal and navigate to a particular section. + */ +export async function openSettingsToSection( + user: UserEvent, + queries: Pick, + sectionLabel: "General" | "Providers" | "Projects" | "Models" +): Promise { + const modal = await openSettingsModal(user, queries); + + if (sectionLabel !== "General") { + const sectionButton = within(modal).getByRole("button", { + name: new RegExp(`^${sectionLabel}$`, "i"), + }); + await user.click(sectionButton); + } + + return modal; +} + +/** + * Add a project via the UI. + * + * Opens the Add Project modal, types the path, and submits. + * Works from both empty state and with existing projects. + */ +export async function addProjectViaUI( + user: UserEvent, + queries: Queries, + projectPath: string +): Promise { + // Try to find "Add Project" button (empty state) or header button + let addButton: HTMLElement; + try { + addButton = await queries.findByText("Add Project"); + } catch { + // Not in empty state, use header button + addButton = await queries.findByRole("button", { name: /add project/i }); + } + await user.click(addButton); + + // Wait for modal to open + const modal = await queries.findByRole("dialog"); + expect(modal).toBeTruthy(); + + // Type the project path in the input + const pathInput = await queries.findByPlaceholderText(/home.*project|path/i); + await user.clear(pathInput); + await user.type(pathInput, projectPath); + + // Click the "Add Project" button in the modal footer + const submitButton = await queries.findByRole("button", { name: /^add project$/i }); + await user.click(submitButton); + + // Wait for modal to close (success) or error to appear + await waitFor( + () => { + // Modal should close on success + expect(queries.queryByRole("dialog")).toBeNull(); + }, + { timeout: 5000 } + ); +} + +/** + * Ensure a project is expanded in the sidebar (no-op if already expanded). + */ +export async function ensureProjectExpanded( + user: UserEvent, + queries: Queries, + projectName: string +): Promise { + const expandLabel = `Expand project ${projectName}`; + const collapseLabel = `Collapse project ${projectName}`; + + if (queries.queryByRole("button", { name: collapseLabel })) { + return; + } + + const expandButton = queries.queryByRole("button", { name: expandLabel }); + if (expandButton) { + await user.click(expandButton); + } + + await waitFor( + () => { + expect(queries.queryByRole("button", { name: collapseLabel })).toBeTruthy(); + }, + { timeout: 5000 } + ); +} + +/** + * Expand a project in the sidebar to reveal its workspaces. + */ +export async function expandProject( + user: UserEvent, + queries: Queries, + projectName: string +): Promise { + const expandButton = await queries.findByRole("button", { + name: `Expand project ${projectName}`, + }); + await user.click(expandButton); + + // Wait for collapse button to appear (confirms expansion) + await queries.findByRole("button", { + name: `Collapse project ${projectName}`, + }); +} + +/** + * Collapse a project in the sidebar. + */ +export async function collapseProject( + user: UserEvent, + queries: Queries, + projectName: string +): Promise { + const collapseButton = await queries.findByRole("button", { + name: `Collapse project ${projectName}`, + }); + await user.click(collapseButton); + + // Wait for expand button to appear (confirms collapse) + await queries.findByRole("button", { + name: `Expand project ${projectName}`, + }); +} + +/** + * Remove a project via the UI. + * + * Expands the project first if needed, then clicks the remove button. + */ +export async function removeProjectViaUI( + user: UserEvent, + queries: Queries, + projectName: string +): Promise { + // Try to expand first (might already be expanded) + try { + await expandProject(user, queries, projectName); + } catch { + // Already expanded, continue + } + + // Click the remove button + const removeButton = await queries.findByRole("button", { + name: `Remove project ${projectName}`, + }); + await user.click(removeButton); + + // Wait for project to disappear + await waitFor(() => { + expect( + queries.queryByRole("button", { name: new RegExp(`project ${projectName}`, "i") }) + ).toBeNull(); + }); +} + +/** + * Select a workspace in the sidebar. + * + * Expands the project first if needed, then clicks the workspace. + */ +export async function selectWorkspace( + user: UserEvent, + queries: Queries, + projectName: string, + workspaceName: string +): Promise { + // Try to expand first (might already be expanded) + try { + await expandProject(user, queries, projectName); + } catch { + // Already expanded, continue + } + + // Wait for the workspace to be ready (not in "creating" state). + await waitFor( + () => { + expect( + queries.queryByRole("button", { + name: `Select workspace ${workspaceName}`, + }) + ).toBeTruthy(); + }, + { timeout: 30000 } + ); + + const workspaceButton = queries.getByRole("button", { + name: `Select workspace ${workspaceName}`, + }); + await user.click(workspaceButton); +} + +/** + * Remove a workspace via the UI. + * + * Expands the project first if needed, then clicks the remove button. + * Handles force delete modal if it appears. + */ +export async function removeWorkspaceViaUI( + user: UserEvent, + queries: Queries, + projectName: string, + workspaceName: string +): Promise { + // Try to expand first (might already be expanded) + try { + await expandProject(user, queries, projectName); + } catch { + // Already expanded, continue + } + + await waitFor( + () => { + expect( + queries.queryByRole("button", { + name: `Remove workspace ${workspaceName}`, + }) + ).toBeTruthy(); + }, + { timeout: 30000 } + ); + + const removeButton = queries.getByRole("button", { + name: `Remove workspace ${workspaceName}`, + }); + await user.click(removeButton); + + // Handle force delete modal if it appears + try { + const forceDeleteButton = await queries.findByRole("button", { + name: /force delete|delete anyway|confirm/i, + }); + await user.click(forceDeleteButton); + } catch { + // No modal appeared, removal was immediate + } + + const workspaceLabel = new RegExp( + `^(select|creating|deleting) workspace ${escapeForRegex(workspaceName)}$`, + "i" + ); + + // Wait for workspace to disappear + await waitFor( + () => { + expect(queries.queryByRole("button", { name: workspaceLabel })).toBeNull(); + }, + { timeout: 30000 } + ); +} + +/** + * Click the "+ New Chat" button for a project. + * + * Expands the project first if needed. + */ +export async function clickNewChat( + user: UserEvent, + queries: Queries, + projectName: string +): Promise { + // Try to expand first (might already be expanded) + try { + await expandProject(user, queries, projectName); + } catch { + // Already expanded, continue + } + + const newChatButton = await queries.findByRole("button", { + name: `New chat in ${projectName}`, + }); + await user.click(newChatButton); +} + +/** + * A workspace created through the UI. + */ +export type CreatedWorkspace = { + workspaceId: string; + workspaceTitle: string; +}; + +function getWorkspaceButtons(): HTMLButtonElement[] { + return Array.from(document.querySelectorAll("button[data-workspace-id]")).filter( + (button): button is HTMLButtonElement => { + const label = button.getAttribute("aria-label") ?? ""; + return /^(select|creating) workspace /i.test(label); + } + ); +} + +function getWorkspaceTitleFromAriaLabel(label: string): string { + return label.replace(/^(select|creating) workspace\s+/i, ""); +} + +/** + * Create a workspace via the UI by opening the creation view, setting the workspace + * name manually (to avoid requiring name-generation), and sending the first message. + */ +const DEFAULT_WORKSPACE_CREATION_PROMPTS = [ + "What's in README.md?", + "What files are in the current directory?", + "Explain quicksort algorithm step by step", + "Create a file called test.txt with 'hello' in it", + "Now read that file", + "What did it contain?", + "Let's summarize the current branches.", +] as const; + +let defaultWorkspacePromptIndex = 0; + +function getDefaultWorkspaceCreationPrompt(): string { + const prompt = + DEFAULT_WORKSPACE_CREATION_PROMPTS[ + defaultWorkspacePromptIndex % DEFAULT_WORKSPACE_CREATION_PROMPTS.length + ]; + defaultWorkspacePromptIndex += 1; + return prompt; +} + +export async function createWorkspaceViaUI( + user: UserEvent, + queries: Queries, + projectName: string, + options: { + workspaceName: string; + firstMessage?: string; + } +): Promise { + const firstMessage = options.firstMessage ?? getDefaultWorkspaceCreationPrompt(); + + const previousHash = window.location.hash; + + // Open creation UI + await clickNewChat(user, queries, projectName); + + // Disable auto-naming by focusing the name input. + const nameInput = await queries.findByPlaceholderText(/workspace-name/i); + await user.click(nameInput); + await user.clear(nameInput); + await user.type(nameInput, options.workspaceName); + + // Send the first message (this triggers workspace creation). + const messageInput = await queries.findByPlaceholderText( + /type your first message to create a workspace/i + ); + await user.click(messageInput); + await user.type(messageInput, firstMessage); + + const sendButton = await queries.findByRole("button", { name: "Send message" }); + await user.click(sendButton); + + await waitFor( + () => { + expect(window.location.hash).toMatch(/^#workspace=/); + expect(window.location.hash).not.toBe(previousHash); + }, + { timeout: 30000 } + ); + + const match = window.location.hash.match(/^#workspace=(.*)$/); + if (!match) { + throw new Error(`Expected workspace hash, got: ${window.location.hash}`); + } + + const workspaceId = decodeURIComponent(match[1]); + + // Wait for the stream to fully settle so the test doesn't leak async work into + // subsequent tests (MockScenarioPlayer schedules delayed events). + await waitFor( + () => { + const input = queries.queryByPlaceholderText(/type a message/i); + expect(input).toBeTruthy(); + const placeholder = input?.getAttribute("placeholder")?.toLowerCase() ?? ""; + expect(placeholder).toContain("to send"); + }, + { timeout: 30000 } + ); + + return { + workspaceId, + workspaceTitle: document.title.split(" - ")[0] ?? "", + }; + +} + +export async function selectWorkspaceById( + user: UserEvent, + workspaceId: string +): Promise { + await waitFor( + () => { + const button = document.querySelector( + `[data-workspace-id="${workspaceId}"][aria-label^="Select workspace"]` + ); + expect(button).toBeTruthy(); + }, + { timeout: 30000 } + ); + + const button = document.querySelector( + `[data-workspace-id="${workspaceId}"][aria-label^="Select workspace"]` + ) as HTMLElement; + await user.click(button); +} + +export async function removeWorkspaceById( + user: UserEvent, + queries: Queries, + workspaceId: string +): Promise { + await waitFor( + () => { + const removeButton = document.querySelector( + `button[data-workspace-id="${workspaceId}"][aria-label^="Remove workspace"]` + ); + expect(removeButton).toBeTruthy(); + }, + { timeout: 30000 } + ); + + const removeButton = document.querySelector( + `button[data-workspace-id="${workspaceId}"][aria-label^="Remove workspace"]` + ) as HTMLButtonElement; + await user.click(removeButton); + + // If a force delete confirmation modal appears, click it. + try { + const modal = await queries.findByRole("dialog"); + if (modal.textContent?.includes("Force delete")) { + const forceDeleteButton = within(modal).getByRole("button", { + name: /force delete/i, + }); + await user.click(forceDeleteButton); + } + } catch { + // No modal, continue + } + + await waitFor( + () => { + expect( + document.querySelector(`button[data-workspace-id="${workspaceId}"]`) + ).toBeNull(); + }, + { timeout: 30000 } + ); +} + +/** + * Get the project name from a full path. + */ +export function getProjectName(projectPath: string): string { + return projectPath.split("/").pop()!; +} + diff --git a/tests/browser/projects/projectManagement.test.tsx b/tests/browser/projects/projectManagement.test.tsx new file mode 100644 index 0000000000..7dd94d719b --- /dev/null +++ b/tests/browser/projects/projectManagement.test.tsx @@ -0,0 +1,146 @@ +/** + * UI integration tests for project management. + */ + +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { shouldRunIntegrationTests } from "../../testUtils"; +import { + renderWithBackend, + createTempGitRepo, + cleanupTempGitRepo, + waitForAppLoad, + addProjectViaUI, + expandProject, + collapseProject, + removeProjectViaUI, + getProjectName, +} from "../harness"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Projects", () => { + describe("Adding Projects", () => { + test("can add a project via the UI", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project through the UI (opens modal, types path, submits) + await addProjectViaUI(user, queries, gitRepo); + + // Project should now appear in sidebar + const projectName = getProjectName(gitRepo); + const expandButton = await queries.findByRole("button", { + name: `Expand project ${projectName}`, + }); + expect(expandButton).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + + test("can open add project modal from sidebar when project exists", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // First add a project via UI + await addProjectViaUI(user, queries, gitRepo); + + // Now click the header "Add project" button to add another + const addButton = await queries.findByRole("button", { name: /add project/i }); + await user.click(addButton); + + // Modal should open + const modal = await queries.findByRole("dialog"); + expect(modal).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); + + describe("Project Display", () => { + test("displays project name in sidebar after adding", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + + // Project should appear with expand button + const projectName = getProjectName(gitRepo); + const expandButton = await queries.findByRole("button", { + name: `Expand project ${projectName}`, + }); + expect(expandButton).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + + test("can expand and collapse project", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Expand via helper + await expandProject(user, queries, projectName); + + // Collapse via helper + await collapseProject(user, queries, projectName); + + // Should be back to collapsed state + const expandButton = await queries.findByRole("button", { + name: `Expand project ${projectName}`, + }); + expect(expandButton).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); + + describe("Removing Projects", () => { + test("can remove project via UI", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Remove via helper + await removeProjectViaUI(user, queries, projectName); + + // Should show empty state + const emptyState = await queries.findByText("No projects"); + expect(emptyState).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); +}); diff --git a/tests/browser/projects/projectRemovalError.test.tsx b/tests/browser/projects/projectRemovalError.test.tsx new file mode 100644 index 0000000000..6f99ce7598 --- /dev/null +++ b/tests/browser/projects/projectRemovalError.test.tsx @@ -0,0 +1,64 @@ +/** + * UI integration tests for project error handling. + */ + +import { waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { shouldRunIntegrationTests } from "../../testUtils"; +import { + renderWithBackend, + createTempGitRepo, + cleanupTempGitRepo, + waitForAppLoad, + addProjectViaUI, + createWorkspaceViaUI, + getProjectName, +} from "../harness"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Projects", () => { + test("removing a project with active workspaces shows an error", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + await createWorkspaceViaUI(user, queries, projectName, { workspaceName: "ws-1" }); + await createWorkspaceViaUI(user, queries, projectName, { workspaceName: "ws-2" }); + + // The remove button is typically shown on hover. + await waitFor(() => { + const btn = document.querySelector(`button[aria-label="Remove project ${projectName}"]`); + expect(btn).toBeTruthy(); + }); + + const removeButton = document.querySelector( + `button[aria-label="Remove project ${projectName}"]` + ) as HTMLElement; + const projectRow = removeButton.closest("[data-project-path]") as HTMLElement | null; + expect(projectRow).toBeTruthy(); + + await user.hover(projectRow!); + await waitFor(() => { + expect(removeButton).toBeVisible(); + }); + + await user.click(removeButton); + + const alert = await queries.findByRole("alert"); + expect(alert).toHaveTextContent( + /cannot remove project with active workspaces\. please remove all 2 workspace\(s\) first\./i + ); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); +}); diff --git a/tests/browser/settings/providersAndModels.test.tsx b/tests/browser/settings/providersAndModels.test.tsx new file mode 100644 index 0000000000..0caaa360ed --- /dev/null +++ b/tests/browser/settings/providersAndModels.test.tsx @@ -0,0 +1,86 @@ +/** + * UI integration tests for Settings. + */ + +import { within, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { shouldRunIntegrationTests } from "../../testUtils"; +import { renderWithBackend, waitForAppLoad, openSettingsToSection } from "../harness"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Settings", () => { + test("Providers: can set OpenAI API key and base URL", async () => { + const user = userEvent.setup(); + + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + const modal = await openSettingsToSection(user, queries, "Providers"); + + const openaiButton = await within(modal).findByRole("button", { + name: /openai/i, + }); + await user.click(openaiButton); + + const apiKeyLabel = await within(modal).findByText("API Key"); + const apiKeyField = apiKeyLabel.parentElement as HTMLElement; + await user.click(within(apiKeyField).getByRole("button", { name: /set|change/i })); + const apiKeyInput = within(apiKeyField).getByPlaceholderText(/enter api key/i); + await user.type(apiKeyInput, "test-api-key{enter}"); + + const baseUrlLabel = await within(modal).findByText("Base URL"); + const baseUrlField = baseUrlLabel.parentElement as HTMLElement; + await user.click(within(baseUrlField).getByRole("button", { name: /set|change/i })); + const baseUrlInput = within(baseUrlField).getByRole("textbox"); + await user.type(baseUrlInput, "https://custom.openai.com/v1{enter}"); + + await waitFor(() => { + expect(openaiButton.querySelector('[title="Configured"]')).toBeTruthy(); + }); + + expect(within(apiKeyField).getByText("••••••••")).toBeInTheDocument(); + expect(within(baseUrlField).getByText("https://custom.openai.com/v1")).toBeInTheDocument(); + } finally { + await cleanup(); + } + }); + + test("Models: can add a custom model", async () => { + const user = userEvent.setup(); + + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + const modal = await openSettingsToSection(user, queries, "Models"); + + // Wait for the form to load. + await within(modal).findByPlaceholderText("model-id"); + + // Click the provider select trigger (placeholder value: "Provider"). + const providerValue = within(modal).getByText("Provider"); + const providerTrigger = providerValue.closest("button"); + expect(providerTrigger).toBeTruthy(); + await user.click(providerTrigger!); + + // Select content is rendered in a portal. + const openaiOption = await queries.findByRole("option", { name: "OpenAI" }); + await user.click(openaiOption); + + const modelIdInput = await within(modal).findByPlaceholderText("model-id"); + await user.type(modelIdInput, "my-custom-model-123"); + + const addButton = within(modal).getByRole("button", { name: /^add$/i }); + await user.click(addButton); + + await waitFor(() => { + expect(within(modal).getByText("my-custom-model-123")).toBeInTheDocument(); + }); + } finally { + await cleanup(); + } + }); +}); diff --git a/tests/browser/workspaces/workspaceManagement.test.tsx b/tests/browser/workspaces/workspaceManagement.test.tsx new file mode 100644 index 0000000000..efc202e862 --- /dev/null +++ b/tests/browser/workspaces/workspaceManagement.test.tsx @@ -0,0 +1,160 @@ +/** + * UI integration tests for workspace management. + */ + +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { shouldRunIntegrationTests } from "../../testUtils"; +import { + renderWithBackend, + createTempGitRepo, + cleanupTempGitRepo, + waitForAppLoad, + addProjectViaUI, + ensureProjectExpanded, + createWorkspaceViaUI, + selectWorkspaceById, + removeWorkspaceById, + clickNewChat, + getProjectName, +} from "../harness"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Workspaces", () => { + describe("Workspace Display", () => { + test("displays workspace under project when expanded", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + const ws = await createWorkspaceViaUI(user, queries, projectName, { + workspaceName: "test-branch", + }); + + await ensureProjectExpanded(user, queries, projectName); + + expect( + document.querySelector(`button[data-workspace-id="${ws.workspaceId}"]`) + ).toBeTruthy(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + + test("displays multiple workspaces under same project", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + const ws1 = await createWorkspaceViaUI(user, queries, projectName, { + workspaceName: "feature-1", + }); + const ws2 = await createWorkspaceViaUI(user, queries, projectName, { + workspaceName: "feature-2", + }); + + await ensureProjectExpanded(user, queries, projectName); + + expect( + document.querySelector(`button[data-workspace-id="${ws1.workspaceId}"]`) + ).toBeTruthy(); + expect( + document.querySelector(`button[data-workspace-id="${ws2.workspaceId}"]`) + ).toBeTruthy(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); + + describe("Selecting Workspaces", () => { + test("clicking workspace selects it", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + const ws = await createWorkspaceViaUI(user, queries, projectName, { + workspaceName: "test-branch", + }); + + await selectWorkspaceById(user, ws.workspaceId); + + expect( + await queries.findByPlaceholderText(/type a message/i) + ).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); + + describe("Removing Workspaces", () => { + test("can remove workspace via UI", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + const ws = await createWorkspaceViaUI(user, queries, projectName, { + workspaceName: "test-branch", + }); + + await removeWorkspaceById(user, queries, ws.workspaceId); + + expect( + document.querySelector(`button[data-workspace-id="${ws.workspaceId}"]`) + ).toBeNull(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); + + describe("Creating Workspaces via UI", () => { + test("clicking + New Chat shows workspace creation UI", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + await clickNewChat(user, queries, projectName); + + const chatInput = await queries.findByPlaceholderText( + /type your first message to create a workspace/i + ); + expect(chatInput).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 23623d851e..75727acf99 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -6,6 +6,11 @@ import assert from "assert"; import "disposablestack/auto"; +// Required by React to avoid noisy act(...) warnings in tests. +// @testing-library/react sets this in many setups, but being explicit keeps +// our Jest environment consistent across projects. +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + assert.equal(typeof Symbol.dispose, "symbol"); // Use fast approximate token counting in Jest to avoid 10s WASM cold starts // Individual tests can override with MUX_FORCE_REAL_TOKENIZER=1