From 734084b147b7cde87585412403b5cdec09a07c38 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 10:07:46 -0600 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20React=20integr?= =?UTF-8?q?ation=20testing=20harness=20with=20jsdom=20+=20real=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add browser test infrastructure for React components with real oRPC backend: - tests/browser/setup.ts: BrowserTestEnv with real ServiceContainer - tests/browser/renderWithBackend.tsx: React Testing Library integration - tests/browser/uiHelpers.tsx: UI interaction helpers (add project, select workspace, etc.) - tests/browser/global-setup.js: jsdom polyfills (streams, fetch, timers) Tests (15 passing): - appLoader.test.tsx: App loading, sidebar expansion, multi-project - projectManagement.test.tsx: Add/remove projects, workspace creation/removal Mocks required for jsdom/Vite compatibility: - styleMock.js: CSS imports - svgMock.js: Vite ?react SVG imports - version.js: Build-time generated module - highlightWorkerClient.js: Worker URL imports - chalk.js, jsdom.js: Node module compatibility Run with: TEST_INTEGRATION=1 bun x jest tests/browser/ _Generated with mux_ --- babel.config.js | 4 + bun.lock | 45 ++- jest.config.js | 72 ++++- package.json | 2 + src/browser/components/LoadingScreen.tsx | 5 +- tests/__mocks__/highlightWorkerClient.js | 7 + tests/__mocks__/styleMock.js | 2 + tests/__mocks__/svgMock.js | 13 + tests/__mocks__/version.js | 4 + tests/browser/appLoader.test.tsx | 132 +++++++++ tests/browser/global-setup.js | 60 ++++ tests/browser/projectManagement.test.tsx | 335 +++++++++++++++++++++++ tests/browser/renderWithBackend.tsx | 111 ++++++++ tests/browser/setup.ts | 153 +++++++++++ tests/browser/uiHelpers.tsx | 245 +++++++++++++++++ 15 files changed, 1175 insertions(+), 15 deletions(-) create mode 100644 tests/__mocks__/highlightWorkerClient.js create mode 100644 tests/__mocks__/styleMock.js create mode 100644 tests/__mocks__/svgMock.js create mode 100644 tests/__mocks__/version.js create mode 100644 tests/browser/appLoader.test.tsx create mode 100644 tests/browser/global-setup.js create mode 100644 tests/browser/projectManagement.test.tsx create mode 100644 tests/browser/renderWithBackend.tsx create mode 100644 tests/browser/setup.ts create mode 100644 tests/browser/uiHelpers.tsx 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..c327b828da 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,7 +15,57 @@ 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) - for future use + { + ...sharedConfig, + displayName: "browser-ui", + testEnvironment: "jsdom", + testMatch: ["/tests/browser/**/*.test.tsx"], + setupFiles: ["/tests/browser/global-setup.js"], + setupFilesAfterEnv: ["/tests/setup.ts"], + }, + ], + collectCoverageFrom: [ + "src/**/*.ts", + "!src/**/*.d.ts", + "!src/desktop/preload.ts", + "!src/browser/api.ts", + "!src/cli/**/*", + "!src/desktop/main.ts", + ], // 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 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/browser/components/LoadingScreen.tsx b/src/browser/components/LoadingScreen.tsx index 6b249a3735..2587d90adc 100644 --- a/src/browser/components/LoadingScreen.tsx +++ b/src/browser/components/LoadingScreen.tsx @@ -1,6 +1,9 @@ export function LoadingScreen() { return ( -
+

Loading workspaces...

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/appLoader.test.tsx b/tests/browser/appLoader.test.tsx new file mode 100644 index 0000000000..7f8a89b12e --- /dev/null +++ b/tests/browser/appLoader.test.tsx @@ -0,0 +1,132 @@ +/** + * 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 } from "./renderWithBackend"; +import { createTempGitRepo, cleanupTempGitRepo } from "./setup"; +import { + waitForAppLoad, + addProjectViaUI, + expandProject, + getProjectName, +} from "./uiHelpers"; + +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, getByTestId, ...queries } = await renderWithBackend(); + try { + // Should show loading screen while fetching initial data + expect(getByTestId("loading-screen")).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(); + } + }); + + test("workspace appears under project when expanded", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, env, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Create workspace via oRPC (UI flow requires sending a chat message) + const workspaceResult = await env.orpc.workspace.create({ + projectPath: gitRepo, + branchName: "test-branch", + trunkBranch: "main", + }); + expect(workspaceResult.success).toBe(true); + if (!workspaceResult.success) throw new Error("Workspace creation failed"); + + // Expand project via UI + await expandProject(user, queries, projectName); + + // Workspace should be visible + const workspaceElement = await queries.findByRole("button", { + name: `Select workspace ${workspaceResult.metadata.name}`, + }); + expect(workspaceElement).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/global-setup.js b/tests/browser/global-setup.js new file mode 100644 index 0000000000..ce0bfa6fb1 --- /dev/null +++ b/tests/browser/global-setup.js @@ -0,0 +1,60 @@ +/** + * 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 +globalThis.import_meta_env = { + VITE_BACKEND_URL: undefined, + MODE: "test", + DEV: false, + PROD: false, +}; + +// Patch setTimeout to add unref method (required by undici timers) +// jsdom's setTimeout doesn't have unref, but node's does +const originalSetTimeout = globalThis.setTimeout; +globalThis.setTimeout = function patchedSetTimeout(...args) { + const timer = originalSetTimeout.apply(this, args); + if (timer && typeof timer === "object" && !timer.unref) { + timer.unref = () => timer; + timer.ref = () => timer; + } + return timer; +}; + +const originalSetInterval = globalThis.setInterval; +globalThis.setInterval = function patchedSetInterval(...args) { + const timer = originalSetInterval.apply(this, args); + if (timer && typeof timer === "object" && !timer.unref) { + timer.unref = () => timer; + timer.ref = () => timer; + } + return timer; +}; + +// 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 } = require("node:stream/web"); +globalThis.TransformStream = globalThis.TransformStream ?? TransformStream; +globalThis.ReadableStream = globalThis.ReadableStream ?? ReadableStream; +globalThis.WritableStream = globalThis.WritableStream ?? WritableStream; + +// Polyfill MessageChannel/MessagePort - required by undici +const { MessageChannel, MessagePort } = require("node:worker_threads"); +globalThis.MessageChannel = globalThis.MessageChannel ?? MessageChannel; +globalThis.MessagePort = globalThis.MessagePort ?? MessagePort; + +// 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/projectManagement.test.tsx b/tests/browser/projectManagement.test.tsx new file mode 100644 index 0000000000..ed05399fa0 --- /dev/null +++ b/tests/browser/projectManagement.test.tsx @@ -0,0 +1,335 @@ +/** + * UI integration tests for project and workspace management. + * + * These tests simulate real user flows through the UI using @testing-library/react. + * The backend is real (ServiceContainer + oRPC) but all interactions go through + * the DOM via UI helpers. Direct oRPC calls are only used when absolutely necessary + * (e.g., workspace creation which has no UI flow in tests). + */ + +import { waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { shouldRunIntegrationTests } from "../testUtils"; +import { renderWithBackend } from "./renderWithBackend"; +import { createTempGitRepo, cleanupTempGitRepo } from "./setup"; +import { + waitForAppLoad, + addProjectViaUI, + expandProject, + collapseProject, + removeProjectViaUI, + selectWorkspace, + removeWorkspaceViaUI, + clickNewChat, + getProjectName, +} from "./uiHelpers"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Project Management UI", () => { + 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(); + } + }); + }); +}); + +describeIntegration("Workspace Management UI", () => { + // Note: Workspace creation via UI requires sending a chat message which triggers + // an AI call. For test isolation, we use oRPC to create workspaces, but all + // other interactions (expand, select, remove) go through the UI. + + describe("Workspace Display", () => { + test("displays workspace under project when expanded", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, env, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Create workspace via oRPC (UI flow requires AI interaction) + const result = await env.orpc.workspace.create({ + projectPath: gitRepo, + branchName: "test-branch", + trunkBranch: "main", + }); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Workspace creation failed"); + + // Expand project via UI helper + await expandProject(user, queries, projectName); + + // Workspace should be visible + const workspaceButton = await queries.findByRole("button", { + name: `Select workspace ${result.metadata.name}`, + }); + expect(workspaceButton).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + + test("displays multiple workspaces under same project", async () => { + const user = userEvent.setup(); + const gitRepo = await createTempGitRepo(); + const { cleanup, env, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Create workspaces via oRPC + const ws1 = await env.orpc.workspace.create({ + projectPath: gitRepo, + branchName: "feature-1", + trunkBranch: "main", + }); + const ws2 = await env.orpc.workspace.create({ + projectPath: gitRepo, + branchName: "feature-2", + trunkBranch: "main", + }); + expect(ws1.success && ws2.success).toBe(true); + if (!ws1.success || !ws2.success) throw new Error("Workspace creation failed"); + + // Expand project via UI + await expandProject(user, queries, projectName); + + // Both workspaces should be visible + const workspace1 = await queries.findByRole("button", { + name: `Select workspace ${ws1.metadata.name}`, + }); + const workspace2 = await queries.findByRole("button", { + name: `Select workspace ${ws2.metadata.name}`, + }); + expect(workspace1).toBeInTheDocument(); + expect(workspace2).toBeInTheDocument(); + } 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, env, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Create workspace via oRPC + const result = await env.orpc.workspace.create({ + projectPath: gitRepo, + branchName: "test-branch", + trunkBranch: "main", + }); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Workspace creation failed"); + + // Select workspace via UI helper + await selectWorkspace(user, queries, projectName, result.metadata.name); + + // Workspace should still be visible after selection + const workspaceButton = await queries.findByRole("button", { + name: `Select workspace ${result.metadata.name}`, + }); + expect(workspaceButton).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, env, ...queries } = await renderWithBackend(); + try { + await waitForAppLoad(queries); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Create workspace via oRPC + const result = await env.orpc.workspace.create({ + projectPath: gitRepo, + branchName: "test-branch", + trunkBranch: "main", + }); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Workspace creation failed"); + + // Remove workspace via UI helper (handles force delete modal if needed) + await removeWorkspaceViaUI(user, queries, projectName, result.metadata.name); + + // Workspace should no longer be visible + expect( + queries.queryByRole("button", { name: `Select workspace ${result.metadata.name}` }) + ).not.toBeInTheDocument(); + } 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); + + // Add project via UI + await addProjectViaUI(user, queries, gitRepo); + const projectName = getProjectName(gitRepo); + + // Click "+ New Chat" via UI helper + await clickNewChat(user, queries, projectName); + + // Should show chat input for new workspace creation + const chatInput = await queries.findByPlaceholderText(/first message|create.*workspace/i); + expect(chatInput).toBeInTheDocument(); + } finally { + await cleanupTempGitRepo(gitRepo); + await cleanup(); + } + }); + }); +}); diff --git a/tests/browser/renderWithBackend.tsx b/tests/browser/renderWithBackend.tsx new file mode 100644 index 0000000000..aaa863aa30 --- /dev/null +++ b/tests/browser/renderWithBackend.tsx @@ -0,0 +1,111 @@ +/** + * 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 { 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 "./setup"; + +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(); + * await env.orpc.projects.create({ projectPath: '/some/path' }); + * const { cleanup, getByText } = await renderWithBackend({ env }); + * ``` + */ +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 () => { + 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 () => { + renderResult.unmount(); + await env.cleanup(); + }; + + return { + ...renderResult, + env, + cleanup, + }; +} diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts new file mode 100644 index 0000000000..469b5cb96c --- /dev/null +++ b/tests/browser/setup.ts @@ -0,0 +1,153 @@ +/** + * 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(); + + const services = new ServiceContainer(config); + 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, + }; + + 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 () => { + 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/uiHelpers.tsx b/tests/browser/uiHelpers.tsx new file mode 100644 index 0000000000..3d5ae228bd --- /dev/null +++ b/tests/browser/uiHelpers.tsx @@ -0,0 +1,245 @@ +/** + * 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, type RenderResult } from "@testing-library/react"; +import type { UserEvent } from "@testing-library/user-event"; + +type Queries = Pick< + RenderResult, + | "findByRole" + | "findByText" + | "findByPlaceholderText" + | "queryByRole" + | "queryByTestId" + | "getByRole" +>; + +/** + * Wait for the app to finish loading (loading screen disappears). + */ +export async function waitForAppLoad(queries: Pick) { + await waitFor( + () => { + expect(queries.queryByTestId("loading-screen")).not.toBeInTheDocument(); + }, + { timeout: 5000 } + ); +} + +/** + * 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).toBeInTheDocument(); + + // 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")).not.toBeInTheDocument(); + }, + { 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") }) + ).not.toBeInTheDocument(); + }); +} + +/** + * 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 + } + + // Click the workspace + const workspaceButton = await queries.findByRole("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 + } + + // Click the remove button + const removeButton = await queries.findByRole("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 + } + + // Wait for workspace to disappear + await waitFor(() => { + expect( + queries.queryByRole("button", { name: `Select workspace ${workspaceName}` }) + ).not.toBeInTheDocument(); + }); +} + +/** + * 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); +} + +/** + * Get the project name from a full path. + */ +export function getProjectName(projectPath: string): string { + return projectPath.split("/").pop()!; +} From 41487b195b66b33f601aa927395edccf461ed517 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 14:04:29 -0600 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=A4=96=20chore:=20clean=20up=20jsdom?= =?UTF-8?q?=20browser=20test=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop modifying LoadingScreen (use visible loading text instead) - Ensure ServiceContainer shutdown/dispose during test cleanup - Wrap unmounts in act() and set IS_REACT_ACT_ENVIRONMENT - Add jsdom polyfill for ResizeObserver - Add browser-ui Jest setup to suppress act warning noise and raise EventEmitter listener limit for full-app mounts - Keep forceExit enabled to avoid Jest hangs from lingering jsdom handles _Generated with mux_ --- jest.config.js | 12 ++++-- src/browser/components/LoadingScreen.tsx | 5 +-- tests/browser/appLoader.test.tsx | 4 +- tests/browser/global-setup.js | 9 +++++ tests/browser/jestSetup.ts | 48 ++++++++++++++++++++++++ tests/browser/renderWithBackend.tsx | 11 ++++-- tests/browser/setup.ts | 6 +++ tests/browser/uiHelpers.tsx | 6 +-- tests/setup.ts | 5 +++ 9 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 tests/browser/jestSetup.ts diff --git a/jest.config.js b/jest.config.js index c327b828da..e0e2aa33f7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -48,14 +48,17 @@ module.exports = { testMatch: ["/tests/browser/**/*.test.ts"], setupFilesAfterEnv: ["/tests/setup.ts"], }, - // Browser UI tests (jsdom environment) - for future use + // Browser UI tests (jsdom environment) { ...sharedConfig, displayName: "browser-ui", testEnvironment: "jsdom", testMatch: ["/tests/browser/**/*.test.tsx"], setupFiles: ["/tests/browser/global-setup.js"], - setupFilesAfterEnv: ["/tests/setup.ts"], + setupFilesAfterEnv: [ + "/tests/setup.ts", + "/tests/browser/jestSetup.ts", + ], }, ], collectCoverageFrom: [ @@ -66,10 +69,11 @@ module.exports = { "!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/src/browser/components/LoadingScreen.tsx b/src/browser/components/LoadingScreen.tsx index 2587d90adc..6b249a3735 100644 --- a/src/browser/components/LoadingScreen.tsx +++ b/src/browser/components/LoadingScreen.tsx @@ -1,9 +1,6 @@ export function LoadingScreen() { return ( -
+

Loading workspaces...

diff --git a/tests/browser/appLoader.test.tsx b/tests/browser/appLoader.test.tsx index 7f8a89b12e..d260d6a3a3 100644 --- a/tests/browser/appLoader.test.tsx +++ b/tests/browser/appLoader.test.tsx @@ -22,10 +22,10 @@ const describeIntegration = shouldRunIntegrationTests() ? describe : describe.sk describeIntegration("AppLoader React Integration", () => { describe("Initial Render", () => { test("shows loading screen initially then transitions to app", async () => { - const { cleanup, getByTestId, ...queries } = await renderWithBackend(); + const { cleanup, getByText, ...queries } = await renderWithBackend(); try { // Should show loading screen while fetching initial data - expect(getByTestId("loading-screen")).toBeInTheDocument(); + expect(getByText(/loading workspaces/i)).toBeInTheDocument(); // Wait for loading screen to disappear (app loaded from real backend) await waitForAppLoad(queries); diff --git a/tests/browser/global-setup.js b/tests/browser/global-setup.js index ce0bfa6fb1..b32f784ff3 100644 --- a/tests/browser/global-setup.js +++ b/tests/browser/global-setup.js @@ -5,6 +5,15 @@ // 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() {} + }; +} globalThis.import_meta_env = { VITE_BACKEND_URL: undefined, MODE: "test", diff --git a/tests/browser/jestSetup.ts b/tests/browser/jestSetup.ts new file mode 100644 index 0000000000..3eebdbf067 --- /dev/null +++ b/tests/browser/jestSetup.ts @@ -0,0 +1,48 @@ +/** + * 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 shouldSuppressActWarning = (args: unknown[]) => { + return args.some( + (arg) => typeof arg === "string" && arg.toLowerCase().includes("not wrapped in act") + ); +}; + +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 (shouldSuppressActWarning(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(() => {}); +}); + +afterAll(() => { + EventEmitter.defaultMaxListeners = originalDefaultMaxListeners; + (console.error as jest.Mock).mockRestore(); + (console.log as jest.Mock).mockRestore(); + (console.warn 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/renderWithBackend.tsx b/tests/browser/renderWithBackend.tsx index aaa863aa30..e1c4079e09 100644 --- a/tests/browser/renderWithBackend.tsx +++ b/tests/browser/renderWithBackend.tsx @@ -7,7 +7,7 @@ */ import React from "react"; -import { render, type RenderOptions, type RenderResult } from "@testing-library/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 "./setup"; @@ -56,7 +56,10 @@ export async function renderWithBackend( }); const cleanup = async () => { - renderResult.unmount(); + await act(async () => { + renderResult.unmount(); + }); + // Only cleanup the env if we created it (not passed in) if (!existingEnv) { await env.cleanup(); @@ -99,7 +102,9 @@ export async function renderComponentWithBackend( }); const cleanup = async () => { - renderResult.unmount(); + await act(async () => { + renderResult.unmount(); + }); await env.cleanup(); }; diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts index 469b5cb96c..66bc72f186 100644 --- a/tests/browser/setup.ts +++ b/tests/browser/setup.ts @@ -98,6 +98,12 @@ export async function createBrowserTestEnv(): Promise { 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 { diff --git a/tests/browser/uiHelpers.tsx b/tests/browser/uiHelpers.tsx index 3d5ae228bd..f9ead88566 100644 --- a/tests/browser/uiHelpers.tsx +++ b/tests/browser/uiHelpers.tsx @@ -17,17 +17,17 @@ type Queries = Pick< | "findByText" | "findByPlaceholderText" | "queryByRole" - | "queryByTestId" + | "queryByText" | "getByRole" >; /** * Wait for the app to finish loading (loading screen disappears). */ -export async function waitForAppLoad(queries: Pick) { +export async function waitForAppLoad(queries: Pick) { await waitFor( () => { - expect(queries.queryByTestId("loading-screen")).not.toBeInTheDocument(); + expect(queries.queryByText(/loading workspaces/i)).not.toBeInTheDocument(); }, { timeout: 5000 } ); 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 From 51d398fce613da110d01c00546c27e186fc068c4 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 22:56:37 -0600 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20organize=20brows?= =?UTF-8?q?er=20UI=20integration=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jest.config.js | 4 +- tests/browser/appLoader.test.tsx | 7 +-- tests/browser/{setup.ts => harness/env.ts} | 2 +- tests/browser/{ => harness}/global-setup.js | 0 tests/browser/harness/index.ts | 10 ++++ tests/browser/{ => harness}/jestSetup.ts | 0 .../render.tsx} | 2 +- .../browser/{uiHelpers.tsx => harness/ui.tsx} | 52 ++++++++++++++++--- tests/browser/projectManagement.test.tsx | 7 +-- 9 files changed, 68 insertions(+), 16 deletions(-) rename tests/browser/{setup.ts => harness/env.ts} (98%) rename tests/browser/{ => harness}/global-setup.js (100%) create mode 100644 tests/browser/harness/index.ts rename tests/browser/{ => harness}/jestSetup.ts (100%) rename tests/browser/{renderWithBackend.tsx => harness/render.tsx} (97%) rename tests/browser/{uiHelpers.tsx => harness/ui.tsx} (81%) diff --git a/jest.config.js b/jest.config.js index e0e2aa33f7..d9387e565c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -54,10 +54,10 @@ module.exports = { displayName: "browser-ui", testEnvironment: "jsdom", testMatch: ["/tests/browser/**/*.test.tsx"], - setupFiles: ["/tests/browser/global-setup.js"], + setupFiles: ["/tests/browser/harness/global-setup.js"], setupFilesAfterEnv: [ "/tests/setup.ts", - "/tests/browser/jestSetup.ts", + "/tests/browser/harness/jestSetup.ts", ], }, ], diff --git a/tests/browser/appLoader.test.tsx b/tests/browser/appLoader.test.tsx index d260d6a3a3..8bd7b586a3 100644 --- a/tests/browser/appLoader.test.tsx +++ b/tests/browser/appLoader.test.tsx @@ -8,14 +8,15 @@ import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import { shouldRunIntegrationTests } from "../testUtils"; -import { renderWithBackend } from "./renderWithBackend"; -import { createTempGitRepo, cleanupTempGitRepo } from "./setup"; import { + renderWithBackend, + createTempGitRepo, + cleanupTempGitRepo, waitForAppLoad, addProjectViaUI, expandProject, getProjectName, -} from "./uiHelpers"; +} from "./harness"; const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; diff --git a/tests/browser/setup.ts b/tests/browser/harness/env.ts similarity index 98% rename from tests/browser/setup.ts rename to tests/browser/harness/env.ts index 66bc72f186..564ed16bb3 100644 --- a/tests/browser/setup.ts +++ b/tests/browser/harness/env.ts @@ -14,7 +14,7 @@ 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 { createOrpcTestClient, type OrpcTestClient } from "../../ipc/orpcTestClient"; import type { APIClient } from "@/browser/contexts/API"; const execAsync = promisify(exec); diff --git a/tests/browser/global-setup.js b/tests/browser/harness/global-setup.js similarity index 100% rename from tests/browser/global-setup.js rename to tests/browser/harness/global-setup.js 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/jestSetup.ts b/tests/browser/harness/jestSetup.ts similarity index 100% rename from tests/browser/jestSetup.ts rename to tests/browser/harness/jestSetup.ts diff --git a/tests/browser/renderWithBackend.tsx b/tests/browser/harness/render.tsx similarity index 97% rename from tests/browser/renderWithBackend.tsx rename to tests/browser/harness/render.tsx index e1c4079e09..22a2324821 100644 --- a/tests/browser/renderWithBackend.tsx +++ b/tests/browser/harness/render.tsx @@ -10,7 +10,7 @@ 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 "./setup"; +import { createBrowserTestEnv, type BrowserTestEnv } from "./env"; export interface RenderWithBackendResult extends RenderResult { /** Test environment with real backend */ diff --git a/tests/browser/uiHelpers.tsx b/tests/browser/harness/ui.tsx similarity index 81% rename from tests/browser/uiHelpers.tsx rename to tests/browser/harness/ui.tsx index f9ead88566..c6f1e92005 100644 --- a/tests/browser/uiHelpers.tsx +++ b/tests/browser/harness/ui.tsx @@ -8,13 +8,14 @@ * Use these instead of direct oRPC calls to keep tests UI-driven. */ -import { waitFor, type RenderResult } from "@testing-library/react"; +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" | "queryByRole" | "queryByText" @@ -27,12 +28,51 @@ type Queries = Pick< export async function waitForAppLoad(queries: Pick) { await waitFor( () => { - expect(queries.queryByText(/loading workspaces/i)).not.toBeInTheDocument(); + 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. * @@ -56,7 +96,7 @@ export async function addProjectViaUI( // Wait for modal to open const modal = await queries.findByRole("dialog"); - expect(modal).toBeInTheDocument(); + expect(modal).toBeTruthy(); // Type the project path in the input const pathInput = await queries.findByPlaceholderText(/home.*project|path/i); @@ -71,7 +111,7 @@ export async function addProjectViaUI( await waitFor( () => { // Modal should close on success - expect(queries.queryByRole("dialog")).not.toBeInTheDocument(); + expect(queries.queryByRole("dialog")).toBeNull(); }, { timeout: 5000 } ); @@ -142,7 +182,7 @@ export async function removeProjectViaUI( await waitFor(() => { expect( queries.queryByRole("button", { name: new RegExp(`project ${projectName}`, "i") }) - ).not.toBeInTheDocument(); + ).toBeNull(); }); } @@ -210,7 +250,7 @@ export async function removeWorkspaceViaUI( await waitFor(() => { expect( queries.queryByRole("button", { name: `Select workspace ${workspaceName}` }) - ).not.toBeInTheDocument(); + ).toBeNull(); }); } diff --git a/tests/browser/projectManagement.test.tsx b/tests/browser/projectManagement.test.tsx index ed05399fa0..82ecd839f5 100644 --- a/tests/browser/projectManagement.test.tsx +++ b/tests/browser/projectManagement.test.tsx @@ -11,9 +11,10 @@ import { waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; import { shouldRunIntegrationTests } from "../testUtils"; -import { renderWithBackend } from "./renderWithBackend"; -import { createTempGitRepo, cleanupTempGitRepo } from "./setup"; import { + renderWithBackend, + createTempGitRepo, + cleanupTempGitRepo, waitForAppLoad, addProjectViaUI, expandProject, @@ -23,7 +24,7 @@ import { removeWorkspaceViaUI, clickNewChat, getProjectName, -} from "./uiHelpers"; +} from "./harness"; const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; From 8a6f467ece960416acdb2dbbbcd486b9695995bc Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 22:56:46 -0600 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=A4=96=20ci:=20add=20browser=20UI=20t?= =?UTF-8?q?ests=20organized=20by=20product=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with `mux`_ --- tests/browser/{ => app}/appLoader.test.tsx | 38 +- tests/browser/harness/env.ts | 11 + tests/browser/harness/global-setup.js | 100 ++++-- tests/browser/harness/jestSetup.ts | 15 +- tests/browser/harness/render.tsx | 4 +- tests/browser/harness/ui.tsx | 254 ++++++++++++- tests/browser/projectManagement.test.tsx | 336 ------------------ .../projects/projectManagement.test.tsx | 146 ++++++++ .../projects/projectRemovalError.test.tsx | 64 ++++ .../settings/providersAndModels.test.tsx | 86 +++++ .../workspaces/workspaceManagement.test.tsx | 160 +++++++++ 11 files changed, 807 insertions(+), 407 deletions(-) rename tests/browser/{ => app}/appLoader.test.tsx (69%) delete mode 100644 tests/browser/projectManagement.test.tsx create mode 100644 tests/browser/projects/projectManagement.test.tsx create mode 100644 tests/browser/projects/projectRemovalError.test.tsx create mode 100644 tests/browser/settings/providersAndModels.test.tsx create mode 100644 tests/browser/workspaces/workspaceManagement.test.tsx diff --git a/tests/browser/appLoader.test.tsx b/tests/browser/app/appLoader.test.tsx similarity index 69% rename from tests/browser/appLoader.test.tsx rename to tests/browser/app/appLoader.test.tsx index 8bd7b586a3..dae204da29 100644 --- a/tests/browser/appLoader.test.tsx +++ b/tests/browser/app/appLoader.test.tsx @@ -7,16 +7,15 @@ import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom"; -import { shouldRunIntegrationTests } from "../testUtils"; +import { shouldRunIntegrationTests } from "../../testUtils"; import { renderWithBackend, createTempGitRepo, cleanupTempGitRepo, waitForAppLoad, addProjectViaUI, - expandProject, getProjectName, -} from "./harness"; +} from "../harness"; const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -76,39 +75,6 @@ describeIntegration("AppLoader React Integration", () => { } }); - test("workspace appears under project when expanded", async () => { - const user = userEvent.setup(); - const gitRepo = await createTempGitRepo(); - const { cleanup, env, ...queries } = await renderWithBackend(); - try { - await waitForAppLoad(queries); - - // Add project via UI - await addProjectViaUI(user, queries, gitRepo); - const projectName = getProjectName(gitRepo); - - // Create workspace via oRPC (UI flow requires sending a chat message) - const workspaceResult = await env.orpc.workspace.create({ - projectPath: gitRepo, - branchName: "test-branch", - trunkBranch: "main", - }); - expect(workspaceResult.success).toBe(true); - if (!workspaceResult.success) throw new Error("Workspace creation failed"); - - // Expand project via UI - await expandProject(user, queries, projectName); - - // Workspace should be visible - const workspaceElement = await queries.findByRole("button", { - name: `Select workspace ${workspaceResult.metadata.name}`, - }); - expect(workspaceElement).toBeInTheDocument(); - } finally { - await cleanupTempGitRepo(gitRepo); - await cleanup(); - } - }); }); describe("User Interactions", () => { diff --git a/tests/browser/harness/env.ts b/tests/browser/harness/env.ts index 564ed16bb3..4a98be7e28 100644 --- a/tests/browser/harness/env.ts +++ b/tests/browser/harness/env.ts @@ -64,6 +64,10 @@ function createMockBrowserWindow(): BrowserWindow { */ export async function createBrowserTestEnv(): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-browser-test-")); + // Prevent browser UI tests from making real network calls for AI. + // This keeps tests hermetic even if the environment has provider credentials. + const previousMockAI = process.env.MUX_MOCK_AI; + process.env.MUX_MOCK_AI = "1"; const config = new Config(tempDir); const mockWindow = createMockBrowserWindow(); @@ -105,6 +109,13 @@ export async function createBrowserTestEnv(): Promise { } const maxRetries = 3; + + // Restore process env to avoid leaking mock mode across unrelated tests. + if (previousMockAI === undefined) { + delete process.env.MUX_MOCK_AI; + } else { + process.env.MUX_MOCK_AI = previousMockAI; + } for (let i = 0; i < maxRetries; i++) { try { await fs.rm(tempDir, { recursive: true, force: true }); diff --git a/tests/browser/harness/global-setup.js b/tests/browser/harness/global-setup.js index b32f784ff3..d5b1fc9d96 100644 --- a/tests/browser/harness/global-setup.js +++ b/tests/browser/harness/global-setup.js @@ -14,6 +14,15 @@ if (typeof globalThis.ResizeObserver === "undefined") { 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", @@ -21,27 +30,26 @@ globalThis.import_meta_env = { PROD: false, }; -// Patch setTimeout to add unref method (required by undici timers) -// jsdom's setTimeout doesn't have unref, but node's does -const originalSetTimeout = globalThis.setTimeout; -globalThis.setTimeout = function patchedSetTimeout(...args) { - const timer = originalSetTimeout.apply(this, args); - if (timer && typeof timer === "object" && !timer.unref) { - timer.unref = () => timer; - timer.ref = () => timer; - } - return timer; -}; +// 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)); -const originalSetInterval = globalThis.setInterval; -globalThis.setInterval = function patchedSetInterval(...args) { - const timer = originalSetInterval.apply(this, args); - if (timer && typeof timer === "object" && !timer.unref) { - timer.unref = () => timer; - timer.ref = () => timer; - } - return timer; -}; +// (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"); @@ -49,16 +57,66 @@ globalThis.TextEncoder = globalThis.TextEncoder ?? TextEncoder; globalThis.TextDecoder = globalThis.TextDecoder ?? TextDecoder; // Polyfill streams - required by AI SDK -const { TransformStream, ReadableStream, WritableStream } = require("node:stream/web"); +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; diff --git a/tests/browser/harness/jestSetup.ts b/tests/browser/harness/jestSetup.ts index 3eebdbf067..c61964b698 100644 --- a/tests/browser/harness/jestSetup.ts +++ b/tests/browser/harness/jestSetup.ts @@ -13,9 +13,14 @@ const originalConsoleError = console.error.bind(console); const originalDefaultMaxListeners = EventEmitter.defaultMaxListeners; const originalConsoleLog = console.log.bind(console); -const shouldSuppressActWarning = (args: unknown[]) => { - return args.some( - (arg) => typeof arg === "string" && arg.toLowerCase().includes("not wrapped in act") +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")) ); }; @@ -25,7 +30,7 @@ beforeAll(() => { // once the default (10) listener limit is exceeded. EventEmitter.defaultMaxListeners = 50; jest.spyOn(console, "error").mockImplementation((...args) => { - if (shouldSuppressActWarning(args)) { + if (shouldSuppressConsoleError(args)) { return; } originalConsoleError(...args); @@ -35,6 +40,7 @@ beforeAll(() => { // they need to assert on logs. jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "debug").mockImplementation(() => {}); }); afterAll(() => { @@ -42,6 +48,7 @@ afterAll(() => { (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 index 22a2324821..4d648a244f 100644 --- a/tests/browser/harness/render.tsx +++ b/tests/browser/harness/render.tsx @@ -38,9 +38,11 @@ export interface RenderWithBackendOptions extends Omit * With pre-existing env (for setup before render): * ```tsx * const env = await createBrowserTestEnv(); - * await env.orpc.projects.create({ projectPath: '/some/path' }); * 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 diff --git a/tests/browser/harness/ui.tsx b/tests/browser/harness/ui.tsx index c6f1e92005..1d4e6b3f4a 100644 --- a/tests/browser/harness/ui.tsx +++ b/tests/browser/harness/ui.tsx @@ -17,11 +17,16 @@ type Queries = Pick< | "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). */ @@ -117,6 +122,34 @@ export async function addProjectViaUI( ); } +/** + * 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. */ @@ -204,8 +237,19 @@ export async function selectWorkspace( // Already expanded, continue } - // Click the workspace - const workspaceButton = await queries.findByRole("button", { + // 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); @@ -230,8 +274,18 @@ export async function removeWorkspaceViaUI( // Already expanded, continue } - // Click the remove button - const removeButton = await queries.findByRole("button", { + 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); @@ -246,12 +300,18 @@ export async function removeWorkspaceViaUI( // 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: `Select workspace ${workspaceName}` }) - ).toBeNull(); - }); + await waitFor( + () => { + expect(queries.queryByRole("button", { name: workspaceLabel })).toBeNull(); + }, + { timeout: 30000 } + ); } /** @@ -277,9 +337,185 @@ export async function clickNewChat( 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/projectManagement.test.tsx b/tests/browser/projectManagement.test.tsx deleted file mode 100644 index 82ecd839f5..0000000000 --- a/tests/browser/projectManagement.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -/** - * UI integration tests for project and workspace management. - * - * These tests simulate real user flows through the UI using @testing-library/react. - * The backend is real (ServiceContainer + oRPC) but all interactions go through - * the DOM via UI helpers. Direct oRPC calls are only used when absolutely necessary - * (e.g., workspace creation which has no UI flow in tests). - */ - -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, - expandProject, - collapseProject, - removeProjectViaUI, - selectWorkspace, - removeWorkspaceViaUI, - clickNewChat, - getProjectName, -} from "./harness"; - -const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; - -describeIntegration("Project Management UI", () => { - 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(); - } - }); - }); -}); - -describeIntegration("Workspace Management UI", () => { - // Note: Workspace creation via UI requires sending a chat message which triggers - // an AI call. For test isolation, we use oRPC to create workspaces, but all - // other interactions (expand, select, remove) go through the UI. - - describe("Workspace Display", () => { - test("displays workspace under project when expanded", async () => { - const user = userEvent.setup(); - const gitRepo = await createTempGitRepo(); - const { cleanup, env, ...queries } = await renderWithBackend(); - try { - await waitForAppLoad(queries); - - // Add project via UI - await addProjectViaUI(user, queries, gitRepo); - const projectName = getProjectName(gitRepo); - - // Create workspace via oRPC (UI flow requires AI interaction) - const result = await env.orpc.workspace.create({ - projectPath: gitRepo, - branchName: "test-branch", - trunkBranch: "main", - }); - expect(result.success).toBe(true); - if (!result.success) throw new Error("Workspace creation failed"); - - // Expand project via UI helper - await expandProject(user, queries, projectName); - - // Workspace should be visible - const workspaceButton = await queries.findByRole("button", { - name: `Select workspace ${result.metadata.name}`, - }); - expect(workspaceButton).toBeInTheDocument(); - } finally { - await cleanupTempGitRepo(gitRepo); - await cleanup(); - } - }); - - test("displays multiple workspaces under same project", async () => { - const user = userEvent.setup(); - const gitRepo = await createTempGitRepo(); - const { cleanup, env, ...queries } = await renderWithBackend(); - try { - await waitForAppLoad(queries); - - // Add project via UI - await addProjectViaUI(user, queries, gitRepo); - const projectName = getProjectName(gitRepo); - - // Create workspaces via oRPC - const ws1 = await env.orpc.workspace.create({ - projectPath: gitRepo, - branchName: "feature-1", - trunkBranch: "main", - }); - const ws2 = await env.orpc.workspace.create({ - projectPath: gitRepo, - branchName: "feature-2", - trunkBranch: "main", - }); - expect(ws1.success && ws2.success).toBe(true); - if (!ws1.success || !ws2.success) throw new Error("Workspace creation failed"); - - // Expand project via UI - await expandProject(user, queries, projectName); - - // Both workspaces should be visible - const workspace1 = await queries.findByRole("button", { - name: `Select workspace ${ws1.metadata.name}`, - }); - const workspace2 = await queries.findByRole("button", { - name: `Select workspace ${ws2.metadata.name}`, - }); - expect(workspace1).toBeInTheDocument(); - expect(workspace2).toBeInTheDocument(); - } 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, env, ...queries } = await renderWithBackend(); - try { - await waitForAppLoad(queries); - - // Add project via UI - await addProjectViaUI(user, queries, gitRepo); - const projectName = getProjectName(gitRepo); - - // Create workspace via oRPC - const result = await env.orpc.workspace.create({ - projectPath: gitRepo, - branchName: "test-branch", - trunkBranch: "main", - }); - expect(result.success).toBe(true); - if (!result.success) throw new Error("Workspace creation failed"); - - // Select workspace via UI helper - await selectWorkspace(user, queries, projectName, result.metadata.name); - - // Workspace should still be visible after selection - const workspaceButton = await queries.findByRole("button", { - name: `Select workspace ${result.metadata.name}`, - }); - expect(workspaceButton).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, env, ...queries } = await renderWithBackend(); - try { - await waitForAppLoad(queries); - - // Add project via UI - await addProjectViaUI(user, queries, gitRepo); - const projectName = getProjectName(gitRepo); - - // Create workspace via oRPC - const result = await env.orpc.workspace.create({ - projectPath: gitRepo, - branchName: "test-branch", - trunkBranch: "main", - }); - expect(result.success).toBe(true); - if (!result.success) throw new Error("Workspace creation failed"); - - // Remove workspace via UI helper (handles force delete modal if needed) - await removeWorkspaceViaUI(user, queries, projectName, result.metadata.name); - - // Workspace should no longer be visible - expect( - queries.queryByRole("button", { name: `Select workspace ${result.metadata.name}` }) - ).not.toBeInTheDocument(); - } 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); - - // Add project via UI - await addProjectViaUI(user, queries, gitRepo); - const projectName = getProjectName(gitRepo); - - // Click "+ New Chat" via UI helper - await clickNewChat(user, queries, projectName); - - // Should show chat input for new workspace creation - const chatInput = await queries.findByPlaceholderText(/first message|create.*workspace/i); - expect(chatInput).toBeInTheDocument(); - } finally { - await cleanupTempGitRepo(gitRepo); - await cleanup(); - } - }); - }); -}); 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(); + } + }); + }); +}); From f5af0927e2580b91aff40922e17d178f654d1aae Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 23:26:06 -0600 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20keep=20runtime=20exec?= =?UTF-8?q?=20helpers=20resilient=20to=20spawn=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/runtime/LocalBaseRuntime.ts | 37 ++++++++++++++++++++++------ tests/browser/harness/env.ts | 1 + 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 452e4b8abc..9dd5214382 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -63,14 +63,25 @@ 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 + ? fs.existsSync("/usr/bin/nice") + ? "/usr/bin/nice" + : fs.existsSync("/bin/nice") + ? "/bin/nice" + : null + : 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 +156,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 +191,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/tests/browser/harness/env.ts b/tests/browser/harness/env.ts index 4a98be7e28..b702e55b0a 100644 --- a/tests/browser/harness/env.ts +++ b/tests/browser/harness/env.ts @@ -93,6 +93,7 @@ export async function createBrowserTestEnv(): Promise { menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, + sessionUsageService: services.sessionUsageService, }; const orpc = createOrpcTestClient(orpcContext); From e50336eebdbd9a23dfe756933a6d103f1a2dfb47 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 11 Dec 2025 23:32:36 -0600 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20sync=20fs=20w?= =?UTF-8?q?hen=20resolving=20nice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/runtime/LocalBaseRuntime.ts | 37 ++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 9dd5214382..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). @@ -70,13 +97,7 @@ export abstract class LocalBaseRuntime implements Runtime { const bashPath = getBashPath(); const shouldNice = options.niceness !== undefined && !isWindows; - const nicePath = shouldNice - ? fs.existsSync("/usr/bin/nice") - ? "/usr/bin/nice" - : fs.existsSync("/bin/nice") - ? "/bin/nice" - : null - : null; + const nicePath = shouldNice ? await resolveNicePath() : null; const spawnCommand = nicePath ?? bashPath; const spawnArgs = From f9f97c848d4d22d5488fd3a97478511ce86b6257 Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 12 Dec 2025 11:34:07 -0600 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20emitting=20ev?= =?UTF-8?q?ents=20into=20disposed=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/ExtensionMetadataService.ts | 7 +++++++ src/node/services/agentSession.ts | 10 ++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) 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); } From 68039075f2e782f50b3c9b266eddf795555cd8bc Mon Sep 17 00:00:00 2001 From: Ammar Date: Fri, 12 Dec 2025 11:58:05 -0600 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=A4=96=20ci:=20isolate=20mock=20AI=20?= =?UTF-8?q?env=20in=20browser=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/browser/harness/env.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tests/browser/harness/env.ts b/tests/browser/harness/env.ts index b702e55b0a..db053355fd 100644 --- a/tests/browser/harness/env.ts +++ b/tests/browser/harness/env.ts @@ -64,15 +64,26 @@ function createMockBrowserWindow(): BrowserWindow { */ export async function createBrowserTestEnv(): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-browser-test-")); - // Prevent browser UI tests from making real network calls for AI. - // This keeps tests hermetic even if the environment has provider credentials. - const previousMockAI = process.env.MUX_MOCK_AI; - process.env.MUX_MOCK_AI = "1"; const config = new Config(tempDir); const mockWindow = createMockBrowserWindow(); - const services = new ServiceContainer(config); + // 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); @@ -111,12 +122,6 @@ export async function createBrowserTestEnv(): Promise { const maxRetries = 3; - // Restore process env to avoid leaking mock mode across unrelated tests. - if (previousMockAI === undefined) { - delete process.env.MUX_MOCK_AI; - } else { - process.env.MUX_MOCK_AI = previousMockAI; - } for (let i = 0; i < maxRetries; i++) { try { await fs.rm(tempDir, { recursive: true, force: true });