refactor: modularize preload.ts into domain-specific modules with tests

This commit is contained in:
Raza Rauf
2026-01-22 20:33:55 +05:00
parent 888b53c718
commit 1c2a8101ee
47 changed files with 10498 additions and 3810 deletions

535
package-lock.json generated
View File

@@ -78,9 +78,10 @@
"@vitest/coverage-v8": "^4.0.15", "@vitest/coverage-v8": "^4.0.15",
"@welldone-software/why-did-you-render": "^8.0.3", "@welldone-software/why-did-you-render": "^8.0.3",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"baseline-browser-mapping": "^2.9.17",
"canvas": "^3.2.0", "canvas": "^3.2.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"electron": "^28.1.0", "electron": "^28.3.3",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"electron-devtools-installer": "^4.0.0", "electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^2.0.1", "electron-playwright-helpers": "^2.0.1",
@@ -91,7 +92,9 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0", "globals": "^16.5.0",
"husky": "^9.1.7",
"jsdom": "^27.2.0", "jsdom": "^27.2.0",
"lint-staged": "^16.2.7",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
"playwright": "^1.57.0", "playwright": "^1.57.0",
"postcss": "^8.4.33", "postcss": "^8.4.33",
@@ -4914,6 +4917,22 @@
"ajv": "^6.9.1" "ajv": "^6.9.1"
} }
}, },
"node_modules/ansi-escapes": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
"integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"environment": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -5709,9 +5728,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.30", "version": "2.9.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
"integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -6562,6 +6581,13 @@
"color-support": "bin.js" "color-support": "bin.js"
} }
}, },
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -7890,9 +7916,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/diff": { "node_modules/diff": {
"version": "8.0.2", "version": "8.0.3",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
"integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.3.1" "node": ">=0.3.1"
@@ -8764,6 +8790,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/err-code": { "node_modules/err-code": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
@@ -10151,6 +10190,19 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -10881,6 +10933,22 @@
"ms": "^2.0.0" "ms": "^2.0.0"
} }
}, },
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-corefoundation": { "node_modules/iconv-corefoundation": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -12197,6 +12265,201 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lint-staged": {
"version": "16.2.7",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz",
"integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^14.0.2",
"listr2": "^9.0.5",
"micromatch": "^4.0.8",
"nano-spawn": "^2.0.0",
"pidtree": "^0.6.0",
"string-argv": "^0.3.2",
"yaml": "^2.8.1"
},
"bin": {
"lint-staged": "bin/lint-staged.js"
},
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://opencollective.com/lint-staged"
}
},
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cli-truncate": "^5.0.0",
"colorette": "^2.0.20",
"eventemitter3": "^5.0.1",
"log-update": "^6.1.0",
"rfdc": "^1.4.1",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/listr2/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/listr2/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/listr2/node_modules/cli-truncate": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
"dev": true,
"license": "MIT",
"dependencies": {
"slice-ansi": "^7.1.0",
"string-width": "^8.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/listr2/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/listr2/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/listr2/node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/listr2/node_modules/string-width": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
"integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/listr2/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/listr2/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/listr2/node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/local-pkg": { "node_modules/local-pkg": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz",
@@ -12311,6 +12574,193 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
"cli-cursor": "^5.0.0",
"slice-ansi": "^7.1.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/log-update/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/log-update/node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/log-update/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
}
},
"node_modules/log-update/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-update/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/log-update/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -12826,9 +13276,9 @@
} }
}, },
"node_modules/mdast-util-to-hast": { "node_modules/mdast-util-to-hast": {
"version": "13.2.0", "version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/hast": "^3.0.0", "@types/hast": "^3.0.0",
@@ -13588,6 +14038,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": { "node_modules/mimic-response": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -13923,6 +14386,19 @@
"thenify-all": "^1.0.0" "thenify-all": "^1.0.0"
} }
}, },
"node_modules/nano-spawn": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz",
"integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.17"
},
"funding": {
"url": "https://github.com/sindresorhus/nano-spawn?sponsor=1"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -14667,6 +15143,19 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pidtree": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true,
"license": "MIT",
"bin": {
"pidtree": "bin/pidtree.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/pify": { "node_modules/pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
@@ -16939,6 +17428,16 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -19784,6 +20283,22 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",

View File

@@ -19,13 +19,14 @@
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
"dev:prod-data": "USE_PROD_DATA=1 concurrently \"npm run dev:main:prod-data\" \"npm run dev:renderer\"", "dev:prod-data": "USE_PROD_DATA=1 concurrently \"npm run dev:main:prod-data\" \"npm run dev:renderer\"",
"dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", "dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev",
"dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && NODE_ENV=development electron .", "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development electron .",
"dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && NODE_ENV=development USE_PROD_DATA=1 electron .", "dev:main:prod-data": "npm run build:prompts && tsc -p tsconfig.main.json && npm run build:preload && NODE_ENV=development USE_PROD_DATA=1 electron .",
"dev:renderer": "vite", "dev:renderer": "vite",
"dev:web": "vite --config vite.config.web.mts", "dev:web": "vite --config vite.config.web.mts",
"build": "npm run build:prompts && npm run build:main && npm run build:renderer && npm run build:web && npm run build:cli", "build": "npm run build:prompts && npm run build:main && npm run build:preload && npm run build:renderer && npm run build:web && npm run build:cli",
"build:prompts": "node scripts/generate-prompts.mjs", "build:prompts": "node scripts/generate-prompts.mjs",
"build:main": "tsc -p tsconfig.main.json", "build:main": "tsc -p tsconfig.main.json",
"build:preload": "node scripts/build-preload.mjs",
"build:cli": "node scripts/build-cli.mjs", "build:cli": "node scripts/build-cli.mjs",
"build:renderer": "vite build", "build:renderer": "vite build",
"build:web": "vite build --config vite.config.web.mts", "build:web": "vite build --config vite.config.web.mts",
@@ -273,9 +274,10 @@
"@vitest/coverage-v8": "^4.0.15", "@vitest/coverage-v8": "^4.0.15",
"@welldone-software/why-did-you-render": "^8.0.3", "@welldone-software/why-did-you-render": "^8.0.3",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"baseline-browser-mapping": "^2.9.17",
"canvas": "^3.2.0", "canvas": "^3.2.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"electron": "^28.1.0", "electron": "^28.3.3",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"electron-devtools-installer": "^4.0.0", "electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^2.0.1", "electron-playwright-helpers": "^2.0.1",

45
scripts/build-preload.mjs Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Build script for the Electron preload script using esbuild.
*
* Bundles the preload script into a single JavaScript file.
* This is necessary because Electron's sandboxed preload environment
* doesn't support multi-file CommonJS requires the same way Node.js does.
*/
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..');
const outfile = path.join(rootDir, 'dist/main/preload.js');
async function build() {
console.log('Building preload script with esbuild...');
try {
await esbuild.build({
entryPoints: [path.join(rootDir, 'src/main/preload/index.ts')],
bundle: true,
platform: 'node',
target: 'node18', // Match Electron's Node version
outfile,
format: 'cjs',
sourcemap: false,
minify: false, // Keep readable for debugging
external: ['electron'], // Don't bundle electron - it's provided by Electron runtime
});
const stats = fs.statSync(outfile);
const sizeKB = (stats.size / 1024).toFixed(1);
console.log(`✓ Built ${outfile} (${sizeKB} KB)`);
} catch (error) {
console.error('Preload build failed:', error);
process.exit(1);
}
}
build();

View File

@@ -0,0 +1,379 @@
/**
* Tests for agents preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createAgentsApi } from '../../../main/preload/agents';
describe('Agents Preload API', () => {
let api: ReturnType<typeof createAgentsApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createAgentsApi();
});
describe('detect', () => {
it('should invoke agents:detect', async () => {
const mockAgents = [
{
id: 'claude-code',
name: 'Claude Code',
command: 'claude',
available: true,
path: '/usr/local/bin/claude',
},
];
mockInvoke.mockResolvedValue(mockAgents);
const result = await api.detect();
expect(mockInvoke).toHaveBeenCalledWith('agents:detect', undefined);
expect(result).toEqual(mockAgents);
});
it('should invoke agents:detect with SSH remote', async () => {
mockInvoke.mockResolvedValue([]);
await api.detect('remote-1');
expect(mockInvoke).toHaveBeenCalledWith('agents:detect', 'remote-1');
});
});
describe('refresh', () => {
it('should invoke agents:refresh without parameters', async () => {
const mockResult = { agents: [], debugInfo: {} };
mockInvoke.mockResolvedValue(mockResult);
const result = await api.refresh();
expect(mockInvoke).toHaveBeenCalledWith('agents:refresh', undefined, undefined);
expect(result).toEqual(mockResult);
});
it('should invoke agents:refresh with agentId', async () => {
mockInvoke.mockResolvedValue({ agents: [], debugInfo: {} });
await api.refresh('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agents:refresh', 'claude-code', undefined);
});
it('should invoke agents:refresh with SSH remote', async () => {
mockInvoke.mockResolvedValue({ agents: [], debugInfo: {} });
await api.refresh(undefined, 'remote-1');
expect(mockInvoke).toHaveBeenCalledWith('agents:refresh', undefined, 'remote-1');
});
});
describe('get', () => {
it('should invoke agents:get with agentId', async () => {
const mockAgent = {
id: 'claude-code',
name: 'Claude Code',
command: 'claude',
available: true,
};
mockInvoke.mockResolvedValue(mockAgent);
const result = await api.get('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agents:get', 'claude-code');
expect(result).toEqual(mockAgent);
});
it('should return null for non-existent agent', async () => {
mockInvoke.mockResolvedValue(null);
const result = await api.get('nonexistent');
expect(result).toBeNull();
});
});
describe('getCapabilities', () => {
it('should invoke agents:getCapabilities with agentId', async () => {
const mockCapabilities = {
supportsResume: true,
supportsReadOnlyMode: true,
supportsJsonOutput: true,
supportsSessionId: true,
supportsImageInput: true,
supportsImageInputOnResume: false,
supportsSlashCommands: true,
supportsSessionStorage: true,
supportsCostTracking: true,
supportsUsageStats: true,
supportsBatchMode: true,
requiresPromptToStart: false,
supportsStreaming: true,
supportsResultMessages: true,
supportsModelSelection: false,
supportsStreamJsonInput: true,
};
mockInvoke.mockResolvedValue(mockCapabilities);
const result = await api.getCapabilities('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agents:getCapabilities', 'claude-code');
expect(result).toEqual(mockCapabilities);
});
});
describe('getConfig', () => {
it('should invoke agents:getConfig with agentId', async () => {
const mockConfig = { theme: 'dark', autoSave: true };
mockInvoke.mockResolvedValue(mockConfig);
const result = await api.getConfig('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agents:getConfig', 'claude-code');
expect(result).toEqual(mockConfig);
});
});
describe('setConfig', () => {
it('should invoke agents:setConfig with agentId and config', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.setConfig('claude-code', { theme: 'light' });
expect(mockInvoke).toHaveBeenCalledWith('agents:setConfig', 'claude-code', {
theme: 'light',
});
expect(result).toBe(true);
});
});
describe('getConfigValue', () => {
it('should invoke agents:getConfigValue with agentId and key', async () => {
mockInvoke.mockResolvedValue('dark');
const result = await api.getConfigValue('claude-code', 'theme');
expect(mockInvoke).toHaveBeenCalledWith('agents:getConfigValue', 'claude-code', 'theme');
expect(result).toBe('dark');
});
});
describe('setConfigValue', () => {
it('should invoke agents:setConfigValue with agentId, key, and value', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.setConfigValue('claude-code', 'theme', 'light');
expect(mockInvoke).toHaveBeenCalledWith(
'agents:setConfigValue',
'claude-code',
'theme',
'light'
);
expect(result).toBe(true);
});
});
describe('setCustomPath', () => {
it('should invoke agents:setCustomPath with agentId and customPath', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.setCustomPath('claude-code', '/custom/path/claude');
expect(mockInvoke).toHaveBeenCalledWith(
'agents:setCustomPath',
'claude-code',
'/custom/path/claude'
);
expect(result).toBe(true);
});
it('should invoke agents:setCustomPath with null to clear', async () => {
mockInvoke.mockResolvedValue(true);
await api.setCustomPath('claude-code', null);
expect(mockInvoke).toHaveBeenCalledWith('agents:setCustomPath', 'claude-code', null);
});
});
describe('getCustomPath', () => {
it('should invoke agents:getCustomPath with agentId', async () => {
mockInvoke.mockResolvedValue('/custom/path/claude');
const result = await api.getCustomPath('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agents:getCustomPath', 'claude-code');
expect(result).toBe('/custom/path/claude');
});
it('should return null when no custom path', async () => {
mockInvoke.mockResolvedValue(null);
const result = await api.getCustomPath('claude-code');
expect(result).toBeNull();
});
});
describe('getAllCustomPaths', () => {
it('should invoke agents:getAllCustomPaths', async () => {
const mockPaths = {
'claude-code': '/custom/claude',
codex: '/custom/codex',
};
mockInvoke.mockResolvedValue(mockPaths);
const result = await api.getAllCustomPaths();
expect(mockInvoke).toHaveBeenCalledWith('agents:getAllCustomPaths');
expect(result).toEqual(mockPaths);
});
});
describe('setCustomArgs', () => {
it('should invoke agents:setCustomArgs', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.setCustomArgs('claude-code', '--verbose --debug');
expect(mockInvoke).toHaveBeenCalledWith(
'agents:setCustomArgs',
'claude-code',
'--verbose --debug'
);
expect(result).toBe(true);
});
});
describe('getCustomArgs', () => {
it('should invoke agents:getCustomArgs', async () => {
mockInvoke.mockResolvedValue('--verbose');
const result = await api.getCustomArgs('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agents:getCustomArgs', 'claude-code');
expect(result).toBe('--verbose');
});
});
describe('getAllCustomArgs', () => {
it('should invoke agents:getAllCustomArgs', async () => {
const mockArgs = { 'claude-code': '--verbose' };
mockInvoke.mockResolvedValue(mockArgs);
const result = await api.getAllCustomArgs();
expect(mockInvoke).toHaveBeenCalledWith('agents:getAllCustomArgs');
expect(result).toEqual(mockArgs);
});
});
describe('setCustomEnvVars', () => {
it('should invoke agents:setCustomEnvVars', async () => {
mockInvoke.mockResolvedValue(true);
const envVars = { CLAUDE_API_KEY: 'test-key' };
const result = await api.setCustomEnvVars('claude-code', envVars);
expect(mockInvoke).toHaveBeenCalledWith('agents:setCustomEnvVars', 'claude-code', envVars);
expect(result).toBe(true);
});
});
describe('getCustomEnvVars', () => {
it('should invoke agents:getCustomEnvVars', async () => {
const envVars = { CLAUDE_API_KEY: 'test-key' };
mockInvoke.mockResolvedValue(envVars);
const result = await api.getCustomEnvVars('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agents:getCustomEnvVars', 'claude-code');
expect(result).toEqual(envVars);
});
});
describe('getAllCustomEnvVars', () => {
it('should invoke agents:getAllCustomEnvVars', async () => {
const allEnvVars = {
'claude-code': { CLAUDE_API_KEY: 'key1' },
codex: { OPENAI_API_KEY: 'key2' },
};
mockInvoke.mockResolvedValue(allEnvVars);
const result = await api.getAllCustomEnvVars();
expect(mockInvoke).toHaveBeenCalledWith('agents:getAllCustomEnvVars');
expect(result).toEqual(allEnvVars);
});
});
describe('getModels', () => {
it('should invoke agents:getModels with agentId', async () => {
const models = ['gpt-4', 'gpt-3.5-turbo'];
mockInvoke.mockResolvedValue(models);
const result = await api.getModels('opencode');
expect(mockInvoke).toHaveBeenCalledWith('agents:getModels', 'opencode', undefined);
expect(result).toEqual(models);
});
it('should invoke agents:getModels with forceRefresh', async () => {
mockInvoke.mockResolvedValue([]);
await api.getModels('opencode', true);
expect(mockInvoke).toHaveBeenCalledWith('agents:getModels', 'opencode', true);
});
});
describe('discoverSlashCommands', () => {
it('should invoke agents:discoverSlashCommands', async () => {
const commands = ['compact', 'help', 'review'];
mockInvoke.mockResolvedValue(commands);
const result = await api.discoverSlashCommands('claude-code', '/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith(
'agents:discoverSlashCommands',
'claude-code',
'/home/user/project',
undefined
);
expect(result).toEqual(commands);
});
it('should invoke agents:discoverSlashCommands with customPath', async () => {
mockInvoke.mockResolvedValue(['help']);
await api.discoverSlashCommands('claude-code', '/home/user/project', '/custom/claude');
expect(mockInvoke).toHaveBeenCalledWith(
'agents:discoverSlashCommands',
'claude-code',
'/home/user/project',
'/custom/claude'
);
});
it('should return null when discovery fails', async () => {
mockInvoke.mockResolvedValue(null);
const result = await api.discoverSlashCommands('unknown-agent', '/home/user/project');
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,129 @@
/**
* Tests for attachments preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createAttachmentsApi } from '../../../main/preload/attachments';
describe('Attachments Preload API', () => {
let api: ReturnType<typeof createAttachmentsApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createAttachmentsApi();
});
describe('save', () => {
it('should invoke attachments:save with sessionId, base64Data, and filename', async () => {
mockInvoke.mockResolvedValue({
success: true,
path: '/path/to/file',
filename: 'image.png',
});
const result = await api.save('session-123', 'base64data', 'image.png');
expect(mockInvoke).toHaveBeenCalledWith(
'attachments:save',
'session-123',
'base64data',
'image.png'
);
expect(result.success).toBe(true);
expect(result.path).toBe('/path/to/file');
expect(result.filename).toBe('image.png');
});
it('should handle errors', async () => {
mockInvoke.mockResolvedValue({ success: false, error: 'Save failed' });
const result = await api.save('session-123', 'base64data', 'image.png');
expect(result.success).toBe(false);
expect(result.error).toBe('Save failed');
});
});
describe('load', () => {
it('should invoke attachments:load with sessionId and filename', async () => {
mockInvoke.mockResolvedValue({
success: true,
dataUrl: 'data:image/png;base64,abc123',
});
const result = await api.load('session-123', 'image.png');
expect(mockInvoke).toHaveBeenCalledWith('attachments:load', 'session-123', 'image.png');
expect(result.success).toBe(true);
expect(result.dataUrl).toBe('data:image/png;base64,abc123');
});
it('should handle missing files', async () => {
mockInvoke.mockResolvedValue({ success: false, error: 'File not found' });
const result = await api.load('session-123', 'nonexistent.png');
expect(result.success).toBe(false);
expect(result.error).toBe('File not found');
});
});
describe('delete', () => {
it('should invoke attachments:delete with sessionId and filename', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.delete('session-123', 'image.png');
expect(mockInvoke).toHaveBeenCalledWith('attachments:delete', 'session-123', 'image.png');
expect(result.success).toBe(true);
});
});
describe('list', () => {
it('should invoke attachments:list with sessionId', async () => {
mockInvoke.mockResolvedValue({
success: true,
files: ['image1.png', 'image2.jpg'],
});
const result = await api.list('session-123');
expect(mockInvoke).toHaveBeenCalledWith('attachments:list', 'session-123');
expect(result.success).toBe(true);
expect(result.files).toEqual(['image1.png', 'image2.jpg']);
});
it('should return empty array when no files exist', async () => {
mockInvoke.mockResolvedValue({ success: true, files: [] });
const result = await api.list('session-123');
expect(result.files).toEqual([]);
});
});
describe('getPath', () => {
it('should invoke attachments:getPath with sessionId', async () => {
mockInvoke.mockResolvedValue({
success: true,
path: '/home/user/.maestro/attachments/session-123',
});
const result = await api.getPath('session-123');
expect(mockInvoke).toHaveBeenCalledWith('attachments:getPath', 'session-123');
expect(result.success).toBe(true);
expect(result.path).toBe('/home/user/.maestro/attachments/session-123');
});
});
});

View File

@@ -0,0 +1,490 @@
/**
* Tests for autorun preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import {
createAutorunApi,
createPlaybooksApi,
createMarketplaceApi,
} from '../../../main/preload/autorun';
describe('Autorun Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createAutorunApi', () => {
let api: ReturnType<typeof createAutorunApi>;
beforeEach(() => {
api = createAutorunApi();
});
describe('listDocs', () => {
it('should invoke autorun:listDocs', async () => {
mockInvoke.mockResolvedValue(['doc1.md', 'doc2.md']);
const result = await api.listDocs('/project/.maestro');
expect(mockInvoke).toHaveBeenCalledWith('autorun:listDocs', '/project/.maestro', undefined);
expect(result).toEqual(['doc1.md', 'doc2.md']);
});
it('should invoke with sshRemoteId', async () => {
mockInvoke.mockResolvedValue([]);
await api.listDocs('/project/.maestro', 'ssh-remote-1');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:listDocs',
'/project/.maestro',
'ssh-remote-1'
);
});
});
describe('hasDocuments', () => {
it('should invoke autorun:hasDocuments', async () => {
mockInvoke.mockResolvedValue({ hasDocuments: true });
const result = await api.hasDocuments('/project');
expect(mockInvoke).toHaveBeenCalledWith('autorun:hasDocuments', '/project');
expect(result.hasDocuments).toBe(true);
});
});
describe('readDoc', () => {
it('should invoke autorun:readDoc', async () => {
mockInvoke.mockResolvedValue('# Document Content');
const result = await api.readDoc('/project/.maestro', 'tasks.md');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:readDoc',
'/project/.maestro',
'tasks.md',
undefined
);
expect(result).toBe('# Document Content');
});
});
describe('writeDoc', () => {
it('should invoke autorun:writeDoc', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.writeDoc('/project/.maestro', 'tasks.md', '# New Content');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:writeDoc',
'/project/.maestro',
'tasks.md',
'# New Content',
undefined
);
});
});
describe('saveImage', () => {
it('should invoke autorun:saveImage', async () => {
mockInvoke.mockResolvedValue({ path: 'image.png' });
await api.saveImage('/project/.maestro', 'doc1', 'base64data', 'png');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:saveImage',
'/project/.maestro',
'doc1',
'base64data',
'png'
);
});
});
describe('deleteImage', () => {
it('should invoke autorun:deleteImage', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.deleteImage('/project/.maestro', 'images/image.png');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:deleteImage',
'/project/.maestro',
'images/image.png'
);
});
});
describe('listImages', () => {
it('should invoke autorun:listImages', async () => {
mockInvoke.mockResolvedValue(['image1.png', 'image2.jpg']);
const result = await api.listImages('/project/.maestro', 'doc1');
expect(mockInvoke).toHaveBeenCalledWith('autorun:listImages', '/project/.maestro', 'doc1');
expect(result).toEqual(['image1.png', 'image2.jpg']);
});
});
describe('deleteFolder', () => {
it('should invoke autorun:deleteFolder', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.deleteFolder('/project');
expect(mockInvoke).toHaveBeenCalledWith('autorun:deleteFolder', '/project');
});
});
describe('watchFolder', () => {
it('should invoke autorun:watchFolder', async () => {
mockInvoke.mockResolvedValue({});
await api.watchFolder('/project/.maestro');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:watchFolder',
'/project/.maestro',
undefined
);
});
it('should handle remote folder', async () => {
mockInvoke.mockResolvedValue({
isRemote: true,
message: 'File watching not available for remote',
});
const result = await api.watchFolder('/project/.maestro', 'ssh-remote-1');
expect(result.isRemote).toBe(true);
});
});
describe('unwatchFolder', () => {
it('should invoke autorun:unwatchFolder', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.unwatchFolder('/project/.maestro');
expect(mockInvoke).toHaveBeenCalledWith('autorun:unwatchFolder', '/project/.maestro');
});
});
describe('onFileChanged', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onFileChanged(callback);
expect(mockOn).toHaveBeenCalledWith('autorun:fileChanged', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, data: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, data: unknown) => void) => {
registeredHandler = handler;
}
);
api.onFileChanged(callback);
const data = { folderPath: '/project', filename: 'tasks.md', eventType: 'change' };
registeredHandler!({}, data);
expect(callback).toHaveBeenCalledWith(data);
});
});
describe('createBackup', () => {
it('should invoke autorun:createBackup', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.createBackup('/project/.maestro', 'tasks.md');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:createBackup',
'/project/.maestro',
'tasks.md'
);
});
});
describe('restoreBackup', () => {
it('should invoke autorun:restoreBackup', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.restoreBackup('/project/.maestro', 'tasks.md');
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:restoreBackup',
'/project/.maestro',
'tasks.md'
);
});
});
describe('deleteBackups', () => {
it('should invoke autorun:deleteBackups', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.deleteBackups('/project/.maestro');
expect(mockInvoke).toHaveBeenCalledWith('autorun:deleteBackups', '/project/.maestro');
});
});
describe('createWorkingCopy', () => {
it('should invoke autorun:createWorkingCopy', async () => {
mockInvoke.mockResolvedValue({
workingCopyPath: '/project/.maestro/tasks.loop-1.md',
originalPath: '/project/.maestro/tasks.md',
});
const result = await api.createWorkingCopy('/project/.maestro', 'tasks.md', 1);
expect(mockInvoke).toHaveBeenCalledWith(
'autorun:createWorkingCopy',
'/project/.maestro',
'tasks.md',
1
);
expect(result.workingCopyPath).toContain('loop-1');
});
});
});
describe('createPlaybooksApi', () => {
let api: ReturnType<typeof createPlaybooksApi>;
beforeEach(() => {
api = createPlaybooksApi();
});
describe('list', () => {
it('should invoke playbooks:list', async () => {
mockInvoke.mockResolvedValue([{ id: 'pb-1', name: 'Playbook 1' }]);
const result = await api.list('session-123');
expect(mockInvoke).toHaveBeenCalledWith('playbooks:list', 'session-123');
expect(result).toEqual([{ id: 'pb-1', name: 'Playbook 1' }]);
});
});
describe('create', () => {
it('should invoke playbooks:create', async () => {
const playbook = {
name: 'New Playbook',
documents: [{ filename: 'tasks.md', resetOnCompletion: false }],
loopEnabled: false,
prompt: 'Run these tasks',
};
mockInvoke.mockResolvedValue({ id: 'pb-new' });
await api.create('session-123', playbook);
expect(mockInvoke).toHaveBeenCalledWith('playbooks:create', 'session-123', playbook);
});
});
describe('update', () => {
it('should invoke playbooks:update', async () => {
const updates = { name: 'Updated Name' };
mockInvoke.mockResolvedValue({ success: true });
await api.update('session-123', 'pb-1', updates);
expect(mockInvoke).toHaveBeenCalledWith('playbooks:update', 'session-123', 'pb-1', updates);
});
});
describe('delete', () => {
it('should invoke playbooks:delete', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.delete('session-123', 'pb-1');
expect(mockInvoke).toHaveBeenCalledWith('playbooks:delete', 'session-123', 'pb-1');
});
});
describe('deleteAll', () => {
it('should invoke playbooks:deleteAll', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.deleteAll('session-123');
expect(mockInvoke).toHaveBeenCalledWith('playbooks:deleteAll', 'session-123');
});
});
describe('export', () => {
it('should invoke playbooks:export', async () => {
mockInvoke.mockResolvedValue({ success: true, path: '/export/playbook.zip' });
await api.export('session-123', 'pb-1', '/project/.maestro');
expect(mockInvoke).toHaveBeenCalledWith(
'playbooks:export',
'session-123',
'pb-1',
'/project/.maestro'
);
});
});
describe('import', () => {
it('should invoke playbooks:import', async () => {
mockInvoke.mockResolvedValue({ success: true, playbookId: 'pb-imported' });
await api.import('session-123', '/project/.maestro');
expect(mockInvoke).toHaveBeenCalledWith(
'playbooks:import',
'session-123',
'/project/.maestro'
);
});
});
});
describe('createMarketplaceApi', () => {
let api: ReturnType<typeof createMarketplaceApi>;
beforeEach(() => {
api = createMarketplaceApi();
});
describe('getManifest', () => {
it('should invoke marketplace:getManifest', async () => {
const manifest = { playbooks: [{ id: 'mp-1', name: 'Marketplace Playbook' }] };
mockInvoke.mockResolvedValue(manifest);
const result = await api.getManifest();
expect(mockInvoke).toHaveBeenCalledWith('marketplace:getManifest');
expect(result).toEqual(manifest);
});
});
describe('refreshManifest', () => {
it('should invoke marketplace:refreshManifest', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.refreshManifest();
expect(mockInvoke).toHaveBeenCalledWith('marketplace:refreshManifest');
});
});
describe('getDocument', () => {
it('should invoke marketplace:getDocument', async () => {
mockInvoke.mockResolvedValue('# Document content');
const result = await api.getDocument('playbooks/example', 'tasks.md');
expect(mockInvoke).toHaveBeenCalledWith(
'marketplace:getDocument',
'playbooks/example',
'tasks.md'
);
expect(result).toBe('# Document content');
});
});
describe('getReadme', () => {
it('should invoke marketplace:getReadme', async () => {
mockInvoke.mockResolvedValue('# README');
const result = await api.getReadme('playbooks/example');
expect(mockInvoke).toHaveBeenCalledWith('marketplace:getReadme', 'playbooks/example');
expect(result).toBe('# README');
});
});
describe('importPlaybook', () => {
it('should invoke marketplace:importPlaybook', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.importPlaybook('mp-1', 'my-playbook', '/project/.maestro', 'session-123');
expect(mockInvoke).toHaveBeenCalledWith(
'marketplace:importPlaybook',
'mp-1',
'my-playbook',
'/project/.maestro',
'session-123',
undefined
);
});
it('should invoke with sshRemoteId', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.importPlaybook(
'mp-1',
'my-playbook',
'/project/.maestro',
'session-123',
'ssh-remote-1'
);
expect(mockInvoke).toHaveBeenCalledWith(
'marketplace:importPlaybook',
'mp-1',
'my-playbook',
'/project/.maestro',
'session-123',
'ssh-remote-1'
);
});
});
describe('onManifestChanged', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onManifestChanged(callback);
expect(mockOn).toHaveBeenCalledWith('marketplace:manifestChanged', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
api.onManifestChanged(callback);
registeredHandler!();
expect(callback).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,285 @@
/**
* Tests for commands preload API
*
* Coverage:
* - createSpeckitApi: getMetadata, getPrompts, getCommand, savePrompt, resetPrompt, refresh
* - createOpenspecApi: getMetadata, getPrompts, getCommand, savePrompt, resetPrompt, refresh
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createSpeckitApi, createOpenspecApi } from '../../../main/preload/commands';
describe('Commands Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createSpeckitApi', () => {
let api: ReturnType<typeof createSpeckitApi>;
beforeEach(() => {
api = createSpeckitApi();
});
describe('getMetadata', () => {
it('should invoke speckit:getMetadata', async () => {
const metadata = {
success: true,
metadata: {
lastRefreshed: '2024-01-01',
commitSha: 'abc123',
sourceVersion: '1.0.0',
sourceUrl: 'https://github.com/example/speckit',
},
};
mockInvoke.mockResolvedValue(metadata);
const result = await api.getMetadata();
expect(mockInvoke).toHaveBeenCalledWith('speckit:getMetadata');
expect(result).toEqual(metadata);
});
});
describe('getPrompts', () => {
it('should invoke speckit:getPrompts', async () => {
const response = {
success: true,
commands: [
{
id: 'cmd-1',
command: '/test',
description: 'Test command',
prompt: 'Test prompt',
isCustom: false,
isModified: false,
},
],
};
mockInvoke.mockResolvedValue(response);
const result = await api.getPrompts();
expect(mockInvoke).toHaveBeenCalledWith('speckit:getPrompts');
expect(result).toEqual(response);
});
});
describe('getCommand', () => {
it('should invoke speckit:getCommand', async () => {
const response = {
success: true,
command: {
id: 'cmd-1',
command: '/test',
description: 'Test command',
prompt: 'Test prompt',
isCustom: false,
isModified: false,
},
};
mockInvoke.mockResolvedValue(response);
const result = await api.getCommand('/test');
expect(mockInvoke).toHaveBeenCalledWith('speckit:getCommand', '/test');
expect(result).toEqual(response);
});
it('should handle command not found', async () => {
mockInvoke.mockResolvedValue({ success: true, command: null });
const result = await api.getCommand('/nonexistent');
expect(result.command).toBeNull();
});
});
describe('savePrompt', () => {
it('should invoke speckit:savePrompt', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.savePrompt('cmd-1', 'Updated prompt content');
expect(mockInvoke).toHaveBeenCalledWith(
'speckit:savePrompt',
'cmd-1',
'Updated prompt content'
);
expect(result.success).toBe(true);
});
it('should handle save error', async () => {
mockInvoke.mockResolvedValue({ success: false, error: 'Failed to save' });
const result = await api.savePrompt('cmd-1', 'content');
expect(result.success).toBe(false);
expect(result.error).toBe('Failed to save');
});
});
describe('resetPrompt', () => {
it('should invoke speckit:resetPrompt', async () => {
mockInvoke.mockResolvedValue({ success: true, prompt: 'Original prompt' });
const result = await api.resetPrompt('cmd-1');
expect(mockInvoke).toHaveBeenCalledWith('speckit:resetPrompt', 'cmd-1');
expect(result.success).toBe(true);
expect(result.prompt).toBe('Original prompt');
});
});
describe('refresh', () => {
it('should invoke speckit:refresh', async () => {
const metadata = {
success: true,
metadata: {
lastRefreshed: '2024-01-02',
commitSha: 'def456',
sourceVersion: '1.0.1',
sourceUrl: 'https://github.com/example/speckit',
},
};
mockInvoke.mockResolvedValue(metadata);
const result = await api.refresh();
expect(mockInvoke).toHaveBeenCalledWith('speckit:refresh');
expect(result).toEqual(metadata);
});
});
});
describe('createOpenspecApi', () => {
let api: ReturnType<typeof createOpenspecApi>;
beforeEach(() => {
api = createOpenspecApi();
});
describe('getMetadata', () => {
it('should invoke openspec:getMetadata', async () => {
const metadata = {
success: true,
metadata: {
lastRefreshed: '2024-01-01',
commitSha: 'abc123',
sourceVersion: '1.0.0',
sourceUrl: 'https://github.com/example/openspec',
},
};
mockInvoke.mockResolvedValue(metadata);
const result = await api.getMetadata();
expect(mockInvoke).toHaveBeenCalledWith('openspec:getMetadata');
expect(result).toEqual(metadata);
});
});
describe('getPrompts', () => {
it('should invoke openspec:getPrompts', async () => {
const response = {
success: true,
commands: [
{
id: 'spec-1',
command: '/spec',
description: 'Spec command',
prompt: 'Spec prompt',
isCustom: false,
isModified: false,
},
],
};
mockInvoke.mockResolvedValue(response);
const result = await api.getPrompts();
expect(mockInvoke).toHaveBeenCalledWith('openspec:getPrompts');
expect(result).toEqual(response);
});
});
describe('getCommand', () => {
it('should invoke openspec:getCommand', async () => {
const response = {
success: true,
command: {
id: 'spec-1',
command: '/spec',
description: 'Spec command',
prompt: 'Spec prompt',
isCustom: false,
isModified: false,
},
};
mockInvoke.mockResolvedValue(response);
const result = await api.getCommand('/spec');
expect(mockInvoke).toHaveBeenCalledWith('openspec:getCommand', '/spec');
expect(result).toEqual(response);
});
});
describe('savePrompt', () => {
it('should invoke openspec:savePrompt', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.savePrompt('spec-1', 'Updated spec prompt');
expect(mockInvoke).toHaveBeenCalledWith(
'openspec:savePrompt',
'spec-1',
'Updated spec prompt'
);
expect(result.success).toBe(true);
});
});
describe('resetPrompt', () => {
it('should invoke openspec:resetPrompt', async () => {
mockInvoke.mockResolvedValue({ success: true, prompt: 'Original spec prompt' });
const result = await api.resetPrompt('spec-1');
expect(mockInvoke).toHaveBeenCalledWith('openspec:resetPrompt', 'spec-1');
expect(result.success).toBe(true);
expect(result.prompt).toBe('Original spec prompt');
});
});
describe('refresh', () => {
it('should invoke openspec:refresh', async () => {
const metadata = {
success: true,
metadata: {
lastRefreshed: '2024-01-02',
commitSha: 'xyz789',
sourceVersion: '2.0.0',
sourceUrl: 'https://github.com/example/openspec',
},
};
mockInvoke.mockResolvedValue(metadata);
const result = await api.refresh();
expect(mockInvoke).toHaveBeenCalledWith('openspec:refresh');
expect(result).toEqual(metadata);
});
});
});
});

View File

@@ -0,0 +1,132 @@
/**
* Tests for context preload API
*
* Coverage:
* - createContextApi: getStoredSession, groomContext, cancelGrooming,
* createGroomingSession (deprecated), sendGroomingPrompt (deprecated), cleanupGroomingSession
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createContextApi } from '../../../main/preload/context';
describe('Context Preload API', () => {
let api: ReturnType<typeof createContextApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createContextApi();
});
describe('getStoredSession', () => {
it('should invoke context:getStoredSession with correct parameters', async () => {
const mockResponse = {
messages: [{ type: 'user', content: 'Hello', timestamp: '2024-01-01', uuid: '123' }],
total: 1,
hasMore: false,
};
mockInvoke.mockResolvedValue(mockResponse);
const result = await api.getStoredSession('claude-code', '/project', 'session-123');
expect(mockInvoke).toHaveBeenCalledWith(
'context:getStoredSession',
'claude-code',
'/project',
'session-123'
);
expect(result).toEqual(mockResponse);
});
it('should return null for non-existent session', async () => {
mockInvoke.mockResolvedValue(null);
const result = await api.getStoredSession('claude-code', '/project', 'non-existent');
expect(result).toBeNull();
});
});
describe('groomContext', () => {
it('should invoke context:groomContext with correct parameters', async () => {
mockInvoke.mockResolvedValue('groomed context response');
const result = await api.groomContext('/project', 'claude-code', 'summarize this');
expect(mockInvoke).toHaveBeenCalledWith(
'context:groomContext',
'/project',
'claude-code',
'summarize this'
);
expect(result).toBe('groomed context response');
});
it('should propagate errors from IPC', async () => {
mockInvoke.mockRejectedValue(new Error('Grooming failed'));
await expect(api.groomContext('/project', 'claude-code', 'prompt')).rejects.toThrow(
'Grooming failed'
);
});
});
describe('cancelGrooming', () => {
it('should invoke context:cancelGrooming', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.cancelGrooming();
expect(mockInvoke).toHaveBeenCalledWith('context:cancelGrooming');
});
});
describe('createGroomingSession (deprecated)', () => {
it('should invoke context:createGroomingSession', async () => {
mockInvoke.mockResolvedValue('grooming-session-id');
const result = await api.createGroomingSession('/project', 'claude-code');
expect(mockInvoke).toHaveBeenCalledWith(
'context:createGroomingSession',
'/project',
'claude-code'
);
expect(result).toBe('grooming-session-id');
});
});
describe('sendGroomingPrompt (deprecated)', () => {
it('should invoke context:sendGroomingPrompt', async () => {
mockInvoke.mockResolvedValue('response text');
const result = await api.sendGroomingPrompt('session-123', 'prompt text');
expect(mockInvoke).toHaveBeenCalledWith(
'context:sendGroomingPrompt',
'session-123',
'prompt text'
);
expect(result).toBe('response text');
});
});
describe('cleanupGroomingSession', () => {
it('should invoke context:cleanupGroomingSession', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.cleanupGroomingSession('session-123');
expect(mockInvoke).toHaveBeenCalledWith('context:cleanupGroomingSession', 'session-123');
});
});
});

View File

@@ -0,0 +1,171 @@
/**
* Tests for debug preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createDebugApi, createDocumentGraphApi } from '../../../main/preload/debug';
describe('Debug Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createDebugApi', () => {
let api: ReturnType<typeof createDebugApi>;
beforeEach(() => {
api = createDebugApi();
});
describe('createPackage', () => {
it('should invoke debug:createPackage without options', async () => {
mockInvoke.mockResolvedValue({ success: true, path: '/tmp/debug-package.zip' });
const result = await api.createPackage();
expect(mockInvoke).toHaveBeenCalledWith('debug:createPackage', undefined);
expect(result).toEqual({ success: true, path: '/tmp/debug-package.zip' });
});
it('should invoke debug:createPackage with options', async () => {
mockInvoke.mockResolvedValue({ success: true, path: '/tmp/debug-package.zip' });
const options = {
includeLogs: true,
includeErrors: true,
includeSessions: false,
includeGroupChats: false,
includeBatchState: true,
};
const result = await api.createPackage(options);
expect(mockInvoke).toHaveBeenCalledWith('debug:createPackage', options);
expect(result.success).toBe(true);
});
it('should handle errors', async () => {
mockInvoke.mockResolvedValue({ success: false, error: 'Failed to create package' });
const result = await api.createPackage();
expect(result.success).toBe(false);
});
});
describe('previewPackage', () => {
it('should invoke debug:previewPackage', async () => {
const preview = {
logs: 150,
errors: 5,
sessions: 10,
groupChats: 2,
estimatedSize: '5.2 MB',
};
mockInvoke.mockResolvedValue(preview);
const result = await api.previewPackage();
expect(mockInvoke).toHaveBeenCalledWith('debug:previewPackage');
expect(result).toEqual(preview);
});
});
});
describe('createDocumentGraphApi', () => {
let api: ReturnType<typeof createDocumentGraphApi>;
beforeEach(() => {
api = createDocumentGraphApi();
});
describe('watchFolder', () => {
it('should invoke documentGraph:watchFolder', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.watchFolder('/project');
expect(mockInvoke).toHaveBeenCalledWith('documentGraph:watchFolder', '/project');
expect(result).toEqual({ success: true });
});
});
describe('unwatchFolder', () => {
it('should invoke documentGraph:unwatchFolder', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.unwatchFolder('/project');
expect(mockInvoke).toHaveBeenCalledWith('documentGraph:unwatchFolder', '/project');
expect(result).toEqual({ success: true });
});
});
describe('onFilesChanged', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onFilesChanged(callback);
expect(mockOn).toHaveBeenCalledWith('documentGraph:filesChanged', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, data: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, data: unknown) => void) => {
registeredHandler = handler;
}
);
api.onFilesChanged(callback);
const data = {
rootPath: '/project',
changes: [
{ filePath: '/project/file1.ts', eventType: 'add' as const },
{ filePath: '/project/file2.ts', eventType: 'change' as const },
],
};
registeredHandler!({}, data);
expect(callback).toHaveBeenCalledWith(data);
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, data: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, data: unknown) => void) => {
registeredHandler = handler;
}
);
const cleanup = api.onFilesChanged(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'documentGraph:filesChanged',
registeredHandler!
);
});
});
});
});

View File

@@ -0,0 +1,361 @@
/**
* Tests for files preload API
*
* Coverage:
* - createTempfileApi: write, read, delete
* - createHistoryApi: getAll, getAllPaginated, add, clear, delete, update, updateSessionName,
* getFilePath, listSessions, onExternalChange, reload
* - createCliApi: getActivity, onActivityChange
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createTempfileApi, createHistoryApi, createCliApi } from '../../../main/preload/files';
describe('Files Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createTempfileApi', () => {
let api: ReturnType<typeof createTempfileApi>;
beforeEach(() => {
api = createTempfileApi();
});
describe('write', () => {
it('should invoke tempfile:write with content', async () => {
mockInvoke.mockResolvedValue('/tmp/tempfile-123.txt');
const result = await api.write('file content');
expect(mockInvoke).toHaveBeenCalledWith('tempfile:write', 'file content', undefined);
expect(result).toBe('/tmp/tempfile-123.txt');
});
it('should invoke tempfile:write with filename', async () => {
mockInvoke.mockResolvedValue('/tmp/custom-name.txt');
const result = await api.write('file content', 'custom-name.txt');
expect(mockInvoke).toHaveBeenCalledWith(
'tempfile:write',
'file content',
'custom-name.txt'
);
expect(result).toBe('/tmp/custom-name.txt');
});
});
describe('read', () => {
it('should invoke tempfile:read with path', async () => {
mockInvoke.mockResolvedValue('file content');
const result = await api.read('/tmp/tempfile-123.txt');
expect(mockInvoke).toHaveBeenCalledWith('tempfile:read', '/tmp/tempfile-123.txt');
expect(result).toBe('file content');
});
});
describe('delete', () => {
it('should invoke tempfile:delete with path', async () => {
mockInvoke.mockResolvedValue(true);
await api.delete('/tmp/tempfile-123.txt');
expect(mockInvoke).toHaveBeenCalledWith('tempfile:delete', '/tmp/tempfile-123.txt');
});
});
});
describe('createHistoryApi', () => {
let api: ReturnType<typeof createHistoryApi>;
beforeEach(() => {
api = createHistoryApi();
});
describe('getAll', () => {
it('should invoke history:getAll without parameters', async () => {
mockInvoke.mockResolvedValue([]);
await api.getAll();
expect(mockInvoke).toHaveBeenCalledWith('history:getAll', undefined, undefined);
});
it('should invoke history:getAll with projectPath', async () => {
mockInvoke.mockResolvedValue([]);
await api.getAll('/project');
expect(mockInvoke).toHaveBeenCalledWith('history:getAll', '/project', undefined);
});
it('should invoke history:getAll with sessionId', async () => {
mockInvoke.mockResolvedValue([]);
await api.getAll('/project', 'session-123');
expect(mockInvoke).toHaveBeenCalledWith('history:getAll', '/project', 'session-123');
});
});
describe('getAllPaginated', () => {
it('should invoke history:getAllPaginated with options', async () => {
mockInvoke.mockResolvedValue({ entries: [], total: 0 });
const options = {
projectPath: '/project',
sessionId: 'session-123',
pagination: { limit: 50, offset: 0 },
};
await api.getAllPaginated(options);
expect(mockInvoke).toHaveBeenCalledWith('history:getAllPaginated', options);
});
});
describe('add', () => {
it('should invoke history:add with entry', async () => {
mockInvoke.mockResolvedValue({ id: 'entry-123' });
const entry = {
id: 'entry-123',
type: 'USER' as const,
timestamp: Date.now(),
summary: 'Test entry',
projectPath: '/project',
};
await api.add(entry);
expect(mockInvoke).toHaveBeenCalledWith('history:add', entry);
});
});
describe('clear', () => {
it('should invoke history:clear without projectPath', async () => {
mockInvoke.mockResolvedValue(true);
await api.clear();
expect(mockInvoke).toHaveBeenCalledWith('history:clear', undefined);
});
it('should invoke history:clear with projectPath', async () => {
mockInvoke.mockResolvedValue(true);
await api.clear('/project');
expect(mockInvoke).toHaveBeenCalledWith('history:clear', '/project');
});
});
describe('delete', () => {
it('should invoke history:delete with entryId', async () => {
mockInvoke.mockResolvedValue(true);
await api.delete('entry-123');
expect(mockInvoke).toHaveBeenCalledWith('history:delete', 'entry-123', undefined);
});
it('should invoke history:delete with sessionId', async () => {
mockInvoke.mockResolvedValue(true);
await api.delete('entry-123', 'session-456');
expect(mockInvoke).toHaveBeenCalledWith('history:delete', 'entry-123', 'session-456');
});
});
describe('update', () => {
it('should invoke history:update with updates', async () => {
mockInvoke.mockResolvedValue(true);
await api.update('entry-123', { validated: true });
expect(mockInvoke).toHaveBeenCalledWith(
'history:update',
'entry-123',
{ validated: true },
undefined
);
});
it('should invoke history:update with sessionId', async () => {
mockInvoke.mockResolvedValue(true);
await api.update('entry-123', { validated: false }, 'session-456');
expect(mockInvoke).toHaveBeenCalledWith(
'history:update',
'entry-123',
{ validated: false },
'session-456'
);
});
});
describe('updateSessionName', () => {
it('should invoke history:updateSessionName', async () => {
mockInvoke.mockResolvedValue(true);
await api.updateSessionName('agent-session-123', 'New Session Name');
expect(mockInvoke).toHaveBeenCalledWith(
'history:updateSessionName',
'agent-session-123',
'New Session Name'
);
});
});
describe('getFilePath', () => {
it('should invoke history:getFilePath', async () => {
mockInvoke.mockResolvedValue('/path/to/history.json');
const result = await api.getFilePath('session-123');
expect(mockInvoke).toHaveBeenCalledWith('history:getFilePath', 'session-123');
expect(result).toBe('/path/to/history.json');
});
});
describe('listSessions', () => {
it('should invoke history:listSessions', async () => {
mockInvoke.mockResolvedValue(['session-1', 'session-2']);
const result = await api.listSessions();
expect(mockInvoke).toHaveBeenCalledWith('history:listSessions');
expect(result).toEqual(['session-1', 'session-2']);
});
});
describe('onExternalChange', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onExternalChange(callback);
expect(mockOn).toHaveBeenCalledWith('history:externalChange', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
api.onExternalChange(callback);
registeredHandler!();
expect(callback).toHaveBeenCalled();
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
const cleanup = api.onExternalChange(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'history:externalChange',
registeredHandler!
);
});
});
describe('reload', () => {
it('should invoke history:reload', async () => {
mockInvoke.mockResolvedValue(true);
await api.reload();
expect(mockInvoke).toHaveBeenCalledWith('history:reload');
});
});
});
describe('createCliApi', () => {
let api: ReturnType<typeof createCliApi>;
beforeEach(() => {
api = createCliApi();
});
describe('getActivity', () => {
it('should invoke cli:getActivity', async () => {
mockInvoke.mockResolvedValue({ active: true, pid: 12345 });
const result = await api.getActivity();
expect(mockInvoke).toHaveBeenCalledWith('cli:getActivity');
expect(result).toEqual({ active: true, pid: 12345 });
});
});
describe('onActivityChange', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onActivityChange(callback);
expect(mockOn).toHaveBeenCalledWith('cli:activityChange', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
api.onActivityChange(callback);
registeredHandler!();
expect(callback).toHaveBeenCalled();
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
const cleanup = api.onActivityChange(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith('cli:activityChange', registeredHandler!);
});
});
});
});

View File

@@ -0,0 +1,212 @@
/**
* Tests for filesystem preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createFsApi } from '../../../main/preload/fs';
describe('Filesystem Preload API', () => {
let api: ReturnType<typeof createFsApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createFsApi();
});
describe('homeDir', () => {
it('should invoke fs:homeDir', async () => {
mockInvoke.mockResolvedValue('/home/user');
const result = await api.homeDir();
expect(mockInvoke).toHaveBeenCalledWith('fs:homeDir');
expect(result).toBe('/home/user');
});
});
describe('readDir', () => {
it('should invoke fs:readDir with dirPath', async () => {
const mockEntries = [
{ name: 'file.txt', isDirectory: false, path: '/home/user/file.txt' },
{ name: 'subdir', isDirectory: true, path: '/home/user/subdir' },
];
mockInvoke.mockResolvedValue(mockEntries);
const result = await api.readDir('/home/user');
expect(mockInvoke).toHaveBeenCalledWith('fs:readDir', '/home/user', undefined);
expect(result).toEqual(mockEntries);
});
it('should invoke fs:readDir with SSH remote', async () => {
mockInvoke.mockResolvedValue([]);
await api.readDir('/home/user', 'remote-1');
expect(mockInvoke).toHaveBeenCalledWith('fs:readDir', '/home/user', 'remote-1');
});
});
describe('readFile', () => {
it('should invoke fs:readFile with filePath', async () => {
mockInvoke.mockResolvedValue('file contents');
const result = await api.readFile('/home/user/file.txt');
expect(mockInvoke).toHaveBeenCalledWith('fs:readFile', '/home/user/file.txt', undefined);
expect(result).toBe('file contents');
});
it('should invoke fs:readFile with SSH remote', async () => {
mockInvoke.mockResolvedValue('remote file contents');
await api.readFile('/home/user/file.txt', 'remote-1');
expect(mockInvoke).toHaveBeenCalledWith('fs:readFile', '/home/user/file.txt', 'remote-1');
});
});
describe('writeFile', () => {
it('should invoke fs:writeFile with filePath and content', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.writeFile('/home/user/file.txt', 'new contents');
expect(mockInvoke).toHaveBeenCalledWith(
'fs:writeFile',
'/home/user/file.txt',
'new contents'
);
expect(result.success).toBe(true);
});
});
describe('stat', () => {
it('should invoke fs:stat with filePath', async () => {
const mockStat = {
size: 1024,
createdAt: '2024-01-01T00:00:00Z',
modifiedAt: '2024-01-02T00:00:00Z',
isDirectory: false,
isFile: true,
};
mockInvoke.mockResolvedValue(mockStat);
const result = await api.stat('/home/user/file.txt');
expect(mockInvoke).toHaveBeenCalledWith('fs:stat', '/home/user/file.txt', undefined);
expect(result).toEqual(mockStat);
});
});
describe('directorySize', () => {
it('should invoke fs:directorySize with dirPath', async () => {
const mockSize = {
totalSize: 10240,
fileCount: 10,
folderCount: 2,
};
mockInvoke.mockResolvedValue(mockSize);
const result = await api.directorySize('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith('fs:directorySize', '/home/user/project', undefined);
expect(result).toEqual(mockSize);
});
});
describe('fetchImageAsBase64', () => {
it('should invoke fs:fetchImageAsBase64 with url', async () => {
mockInvoke.mockResolvedValue('base64encodedimage');
const result = await api.fetchImageAsBase64('https://example.com/image.png');
expect(mockInvoke).toHaveBeenCalledWith(
'fs:fetchImageAsBase64',
'https://example.com/image.png'
);
expect(result).toBe('base64encodedimage');
});
it('should return null for failed fetch', async () => {
mockInvoke.mockResolvedValue(null);
const result = await api.fetchImageAsBase64('https://example.com/notfound.png');
expect(result).toBeNull();
});
});
describe('rename', () => {
it('should invoke fs:rename with oldPath and newPath', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.rename('/home/user/old.txt', '/home/user/new.txt');
expect(mockInvoke).toHaveBeenCalledWith(
'fs:rename',
'/home/user/old.txt',
'/home/user/new.txt',
undefined
);
expect(result.success).toBe(true);
});
it('should invoke fs:rename with SSH remote', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.rename('/home/user/old.txt', '/home/user/new.txt', 'remote-1');
expect(mockInvoke).toHaveBeenCalledWith(
'fs:rename',
'/home/user/old.txt',
'/home/user/new.txt',
'remote-1'
);
});
});
describe('delete', () => {
it('should invoke fs:delete with targetPath', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.delete('/home/user/file.txt');
expect(mockInvoke).toHaveBeenCalledWith('fs:delete', '/home/user/file.txt', undefined);
expect(result.success).toBe(true);
});
it('should invoke fs:delete with options', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.delete('/home/user/dir', { recursive: true, sshRemoteId: 'remote-1' });
expect(mockInvoke).toHaveBeenCalledWith('fs:delete', '/home/user/dir', {
recursive: true,
sshRemoteId: 'remote-1',
});
});
});
describe('countItems', () => {
it('should invoke fs:countItems with dirPath', async () => {
mockInvoke.mockResolvedValue({ fileCount: 5, folderCount: 2 });
const result = await api.countItems('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith('fs:countItems', '/home/user/project', undefined);
expect(result.fileCount).toBe(5);
expect(result.folderCount).toBe(2);
});
});
});

View File

@@ -0,0 +1,324 @@
/**
* Tests for git preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createGitApi } from '../../../main/preload/git';
describe('Git Preload API', () => {
let api: ReturnType<typeof createGitApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createGitApi();
});
describe('status', () => {
it('should invoke git:status with cwd', async () => {
mockInvoke.mockResolvedValue('M src/file.ts');
const result = await api.status('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith(
'git:status',
'/home/user/project',
undefined,
undefined
);
expect(result).toBe('M src/file.ts');
});
it('should invoke git:status with SSH remote parameters', async () => {
mockInvoke.mockResolvedValue('M src/file.ts');
await api.status('/home/user/project', 'remote-1', '/remote/cwd');
expect(mockInvoke).toHaveBeenCalledWith(
'git:status',
'/home/user/project',
'remote-1',
'/remote/cwd'
);
});
});
describe('diff', () => {
it('should invoke git:diff with cwd and optional file', async () => {
mockInvoke.mockResolvedValue('diff output');
const result = await api.diff('/home/user/project', 'src/file.ts');
expect(mockInvoke).toHaveBeenCalledWith(
'git:diff',
'/home/user/project',
'src/file.ts',
undefined,
undefined
);
expect(result).toBe('diff output');
});
it('should invoke git:diff without file', async () => {
mockInvoke.mockResolvedValue('full diff');
await api.diff('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith(
'git:diff',
'/home/user/project',
undefined,
undefined,
undefined
);
});
});
describe('isRepo', () => {
it('should invoke git:isRepo and return true for git repo', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.isRepo('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith(
'git:isRepo',
'/home/user/project',
undefined,
undefined
);
expect(result).toBe(true);
});
it('should return false for non-git directory', async () => {
mockInvoke.mockResolvedValue(false);
const result = await api.isRepo('/home/user/not-a-repo');
expect(result).toBe(false);
});
});
describe('branch', () => {
it('should invoke git:branch and return current branch', async () => {
mockInvoke.mockResolvedValue({ stdout: 'main', stderr: '' });
const result = await api.branch('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith(
'git:branch',
'/home/user/project',
undefined,
undefined
);
expect(result.stdout).toBe('main');
});
});
describe('info', () => {
it('should invoke git:info and return comprehensive info', async () => {
const mockInfo = {
branch: 'main',
remote: 'origin',
behind: 2,
ahead: 1,
uncommittedChanges: 3,
};
mockInvoke.mockResolvedValue(mockInfo);
const result = await api.info('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith(
'git:info',
'/home/user/project',
undefined,
undefined
);
expect(result).toEqual(mockInfo);
});
});
describe('log', () => {
it('should invoke git:log with options', async () => {
const mockEntries = [
{
hash: 'abc123',
shortHash: 'abc',
author: 'User',
date: '2024-01-01',
refs: ['HEAD'],
subject: 'Initial commit',
},
];
mockInvoke.mockResolvedValue({ entries: mockEntries, error: null });
const result = await api.log('/home/user/project', { limit: 10, search: 'fix' });
expect(mockInvoke).toHaveBeenCalledWith('git:log', '/home/user/project', {
limit: 10,
search: 'fix',
});
expect(result.entries).toEqual(mockEntries);
});
});
describe('worktreeInfo', () => {
it('should invoke git:worktreeInfo', async () => {
mockInvoke.mockResolvedValue({
success: true,
exists: true,
isWorktree: true,
currentBranch: 'feature-branch',
repoRoot: '/home/user/project',
});
const result = await api.worktreeInfo('/home/user/worktree');
expect(mockInvoke).toHaveBeenCalledWith('git:worktreeInfo', '/home/user/worktree', undefined);
expect(result.success).toBe(true);
expect(result.isWorktree).toBe(true);
});
});
describe('worktreeSetup', () => {
it('should invoke git:worktreeSetup with all parameters', async () => {
mockInvoke.mockResolvedValue({
success: true,
created: true,
currentBranch: 'feature-branch',
});
const result = await api.worktreeSetup(
'/home/user/project',
'/home/user/worktree',
'feature-branch',
'remote-1'
);
expect(mockInvoke).toHaveBeenCalledWith(
'git:worktreeSetup',
'/home/user/project',
'/home/user/worktree',
'feature-branch',
'remote-1'
);
expect(result.success).toBe(true);
expect(result.created).toBe(true);
});
});
describe('createPR', () => {
it('should invoke git:createPR', async () => {
mockInvoke.mockResolvedValue({
success: true,
prUrl: 'https://github.com/user/repo/pull/123',
});
const result = await api.createPR('/home/user/worktree', 'main', 'Feature Title', 'PR body');
expect(mockInvoke).toHaveBeenCalledWith(
'git:createPR',
'/home/user/worktree',
'main',
'Feature Title',
'PR body',
undefined
);
expect(result.success).toBe(true);
expect(result.prUrl).toBe('https://github.com/user/repo/pull/123');
});
});
describe('checkGhCli', () => {
it('should invoke git:checkGhCli', async () => {
mockInvoke.mockResolvedValue({ installed: true, authenticated: true });
const result = await api.checkGhCli();
expect(mockInvoke).toHaveBeenCalledWith('git:checkGhCli', undefined);
expect(result.installed).toBe(true);
expect(result.authenticated).toBe(true);
});
});
describe('createGist', () => {
it('should invoke git:createGist', async () => {
mockInvoke.mockResolvedValue({
success: true,
gistUrl: 'https://gist.github.com/user/abc123',
});
const result = await api.createGist('file.txt', 'content', 'description', true);
expect(mockInvoke).toHaveBeenCalledWith(
'git:createGist',
'file.txt',
'content',
'description',
true,
undefined
);
expect(result.success).toBe(true);
expect(result.gistUrl).toBe('https://gist.github.com/user/abc123');
});
});
describe('listWorktrees', () => {
it('should invoke git:listWorktrees', async () => {
const mockWorktrees = [
{ path: '/home/user/project', head: 'abc123', branch: 'main', isBare: false },
{ path: '/home/user/worktree', head: 'def456', branch: 'feature', isBare: false },
];
mockInvoke.mockResolvedValue({ worktrees: mockWorktrees });
const result = await api.listWorktrees('/home/user/project');
expect(mockInvoke).toHaveBeenCalledWith('git:listWorktrees', '/home/user/project', undefined);
expect(result.worktrees).toEqual(mockWorktrees);
});
});
describe('onWorktreeDiscovered', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onWorktreeDiscovered(callback);
expect(mockOn).toHaveBeenCalledWith('worktree:discovered', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback with worktree data', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, data: unknown) => void;
mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => {
if (channel === 'worktree:discovered') {
registeredHandler = handler;
}
});
api.onWorktreeDiscovered(callback);
const data = {
sessionId: 'session-123',
worktree: { path: '/home/user/worktree', name: 'feature', branch: 'feature-branch' },
};
registeredHandler!({}, data);
expect(callback).toHaveBeenCalledWith(data);
});
});
});

View File

@@ -0,0 +1,729 @@
/**
* Tests for groupChat preload API
*
* Coverage:
* - createGroupChatApi: Storage, chat log, moderator, participant, history, export operations
* - Event subscriptions: onMessage, onStateChange, onParticipantsChanged, onModeratorUsage,
* onHistoryEntry, onParticipantState, onModeratorSessionIdChanged
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createGroupChatApi } from '../../../main/preload/groupChat';
describe('GroupChat Preload API', () => {
let api: ReturnType<typeof createGroupChatApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createGroupChatApi();
});
describe('Storage operations', () => {
describe('create', () => {
it('should invoke groupChat:create', async () => {
mockInvoke.mockResolvedValue({ id: 'gc-123' });
const result = await api.create('My Group Chat', 'claude-code');
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:create',
'My Group Chat',
'claude-code',
undefined
);
expect(result).toEqual({ id: 'gc-123' });
});
it('should invoke with moderator config', async () => {
mockInvoke.mockResolvedValue({ id: 'gc-123' });
const moderatorConfig = { customPath: '/custom/path' };
await api.create('My Group Chat', 'claude-code', moderatorConfig);
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:create',
'My Group Chat',
'claude-code',
moderatorConfig
);
});
});
describe('list', () => {
it('should invoke groupChat:list', async () => {
mockInvoke.mockResolvedValue([{ id: 'gc-1', name: 'Chat 1' }]);
const result = await api.list();
expect(mockInvoke).toHaveBeenCalledWith('groupChat:list');
expect(result).toEqual([{ id: 'gc-1', name: 'Chat 1' }]);
});
});
describe('load', () => {
it('should invoke groupChat:load', async () => {
mockInvoke.mockResolvedValue({ id: 'gc-123', name: 'My Chat', participants: [] });
const result = await api.load('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:load', 'gc-123');
expect(result.id).toBe('gc-123');
});
});
describe('delete', () => {
it('should invoke groupChat:delete', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.delete('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:delete', 'gc-123');
});
});
describe('rename', () => {
it('should invoke groupChat:rename', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.rename('gc-123', 'New Name');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:rename', 'gc-123', 'New Name');
});
});
describe('update', () => {
it('should invoke groupChat:update', async () => {
mockInvoke.mockResolvedValue({ success: true });
const updates = { name: 'Updated', moderatorAgentId: 'opencode' };
await api.update('gc-123', updates);
expect(mockInvoke).toHaveBeenCalledWith('groupChat:update', 'gc-123', updates);
});
});
});
describe('Chat log operations', () => {
describe('appendMessage', () => {
it('should invoke groupChat:appendMessage', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.appendMessage('gc-123', 'Moderator', 'Hello everyone!');
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:appendMessage',
'gc-123',
'Moderator',
'Hello everyone!'
);
});
});
describe('getMessages', () => {
it('should invoke groupChat:getMessages', async () => {
const messages = [{ timestamp: '2024-01-01', from: 'User', content: 'Hi' }];
mockInvoke.mockResolvedValue(messages);
const result = await api.getMessages('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:getMessages', 'gc-123');
expect(result).toEqual(messages);
});
});
describe('saveImage', () => {
it('should invoke groupChat:saveImage', async () => {
mockInvoke.mockResolvedValue({ path: 'images/img.png' });
await api.saveImage('gc-123', 'base64data', 'image.png');
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:saveImage',
'gc-123',
'base64data',
'image.png'
);
});
});
});
describe('Moderator operations', () => {
describe('startModerator', () => {
it('should invoke groupChat:startModerator', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.startModerator('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:startModerator', 'gc-123');
});
});
describe('sendToModerator', () => {
it('should invoke groupChat:sendToModerator', async () => {
mockInvoke.mockResolvedValue({ response: 'Moderator response' });
await api.sendToModerator('gc-123', 'Please coordinate', undefined, false);
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:sendToModerator',
'gc-123',
'Please coordinate',
undefined,
false
);
});
it('should invoke with images and readOnly', async () => {
mockInvoke.mockResolvedValue({ response: 'Response' });
await api.sendToModerator('gc-123', 'Message', ['image1.png'], true);
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:sendToModerator',
'gc-123',
'Message',
['image1.png'],
true
);
});
});
describe('stopModerator', () => {
it('should invoke groupChat:stopModerator', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.stopModerator('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:stopModerator', 'gc-123');
});
});
describe('getModeratorSessionId', () => {
it('should invoke groupChat:getModeratorSessionId', async () => {
mockInvoke.mockResolvedValue('mod-session-456');
const result = await api.getModeratorSessionId('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:getModeratorSessionId', 'gc-123');
expect(result).toBe('mod-session-456');
});
});
});
describe('Participant operations', () => {
describe('addParticipant', () => {
it('should invoke groupChat:addParticipant', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.addParticipant('gc-123', 'Agent1', 'claude-code');
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:addParticipant',
'gc-123',
'Agent1',
'claude-code',
undefined
);
});
it('should invoke with cwd', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.addParticipant('gc-123', 'Agent1', 'claude-code', '/project');
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:addParticipant',
'gc-123',
'Agent1',
'claude-code',
'/project'
);
});
});
describe('sendToParticipant', () => {
it('should invoke groupChat:sendToParticipant', async () => {
mockInvoke.mockResolvedValue({ response: 'Participant response' });
await api.sendToParticipant('gc-123', 'Agent1', 'Do this task');
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:sendToParticipant',
'gc-123',
'Agent1',
'Do this task',
undefined
);
});
it('should invoke with images', async () => {
mockInvoke.mockResolvedValue({ response: 'Response' });
await api.sendToParticipant('gc-123', 'Agent1', 'Look at this', ['screenshot.png']);
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:sendToParticipant',
'gc-123',
'Agent1',
'Look at this',
['screenshot.png']
);
});
});
describe('removeParticipant', () => {
it('should invoke groupChat:removeParticipant', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.removeParticipant('gc-123', 'Agent1');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:removeParticipant', 'gc-123', 'Agent1');
});
});
describe('resetParticipantContext', () => {
it('should invoke groupChat:resetParticipantContext', async () => {
mockInvoke.mockResolvedValue({ newAgentSessionId: 'new-session-789' });
const result = await api.resetParticipantContext('gc-123', 'Agent1');
expect(mockInvoke).toHaveBeenCalledWith(
'groupChat:resetParticipantContext',
'gc-123',
'Agent1',
undefined
);
expect(result.newAgentSessionId).toBe('new-session-789');
});
});
});
describe('History operations', () => {
describe('getHistory', () => {
it('should invoke groupChat:getHistory', async () => {
const history = [{ id: 'h-1', timestamp: Date.now(), summary: 'Test' }];
mockInvoke.mockResolvedValue(history);
const result = await api.getHistory('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:getHistory', 'gc-123');
expect(result).toEqual(history);
});
});
describe('addHistoryEntry', () => {
it('should invoke groupChat:addHistoryEntry', async () => {
mockInvoke.mockResolvedValue({ id: 'h-new' });
const entry = {
timestamp: Date.now(),
summary: 'Task completed',
participantName: 'Agent1',
participantColor: '#ff0000',
type: 'response' as const,
};
await api.addHistoryEntry('gc-123', entry);
expect(mockInvoke).toHaveBeenCalledWith('groupChat:addHistoryEntry', 'gc-123', entry);
});
});
describe('deleteHistoryEntry', () => {
it('should invoke groupChat:deleteHistoryEntry', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.deleteHistoryEntry('gc-123', 'h-1');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:deleteHistoryEntry', 'gc-123', 'h-1');
});
});
describe('clearHistory', () => {
it('should invoke groupChat:clearHistory', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.clearHistory('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:clearHistory', 'gc-123');
});
});
describe('getHistoryFilePath', () => {
it('should invoke groupChat:getHistoryFilePath', async () => {
mockInvoke.mockResolvedValue('/path/to/history.json');
const result = await api.getHistoryFilePath('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:getHistoryFilePath', 'gc-123');
expect(result).toBe('/path/to/history.json');
});
});
});
describe('Export operations', () => {
describe('getImages', () => {
it('should invoke groupChat:getImages', async () => {
const images = { 'img1.png': 'base64data1', 'img2.png': 'base64data2' };
mockInvoke.mockResolvedValue(images);
const result = await api.getImages('gc-123');
expect(mockInvoke).toHaveBeenCalledWith('groupChat:getImages', 'gc-123');
expect(result).toEqual(images);
});
});
});
describe('Event subscriptions', () => {
describe('onMessage', () => {
it('should register and handle message events', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, message: unknown) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, message: unknown) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onMessage(callback);
expect(mockOn).toHaveBeenCalledWith('groupChat:message', expect.any(Function));
const message = { timestamp: '2024-01-01', from: 'User', content: 'Hi' };
registeredHandler!({}, 'gc-123', message);
expect(callback).toHaveBeenCalledWith('gc-123', message);
expect(typeof cleanup).toBe('function');
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, message: unknown) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, message: unknown) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onMessage(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith('groupChat:message', registeredHandler!);
});
});
describe('onStateChange', () => {
it('should register and handle state change events', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, state: string) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, state: string) => void
) => {
registeredHandler = handler;
}
);
api.onStateChange(callback);
registeredHandler!({}, 'gc-123', 'moderator-thinking');
expect(callback).toHaveBeenCalledWith('gc-123', 'moderator-thinking');
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, state: string) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, state: string) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onStateChange(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'groupChat:stateChange',
registeredHandler!
);
});
});
describe('onParticipantsChanged', () => {
it('should register and handle participants changed events', () => {
const callback = vi.fn();
let registeredHandler: (
event: unknown,
groupChatId: string,
participants: unknown[]
) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, participants: unknown[]) => void
) => {
registeredHandler = handler;
}
);
api.onParticipantsChanged(callback);
const participants = [{ name: 'Agent1', agentId: 'claude-code' }];
registeredHandler!({}, 'gc-123', participants);
expect(callback).toHaveBeenCalledWith('gc-123', participants);
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (
event: unknown,
groupChatId: string,
participants: unknown[]
) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, participants: unknown[]) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onParticipantsChanged(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'groupChat:participantsChanged',
registeredHandler!
);
});
});
describe('onModeratorUsage', () => {
it('should register and handle moderator usage events', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, usage: unknown) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, usage: unknown) => void
) => {
registeredHandler = handler;
}
);
api.onModeratorUsage(callback);
const usage = { contextUsage: 50, totalCost: 0.05, tokenCount: 1000 };
registeredHandler!({}, 'gc-123', usage);
expect(callback).toHaveBeenCalledWith('gc-123', usage);
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, usage: unknown) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, usage: unknown) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onModeratorUsage(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'groupChat:moderatorUsage',
registeredHandler!
);
});
});
describe('onHistoryEntry', () => {
it('should register and handle history entry events', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, entry: unknown) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, entry: unknown) => void
) => {
registeredHandler = handler;
}
);
api.onHistoryEntry(callback);
const entry = { id: 'h-1', summary: 'Task done' };
registeredHandler!({}, 'gc-123', entry);
expect(callback).toHaveBeenCalledWith('gc-123', entry);
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, entry: unknown) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, entry: unknown) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onHistoryEntry(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'groupChat:historyEntry',
registeredHandler!
);
});
});
describe('onParticipantState', () => {
it('should register and handle participant state events', () => {
const callback = vi.fn();
let registeredHandler: (
event: unknown,
groupChatId: string,
participantName: string,
state: string
) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (
event: unknown,
groupChatId: string,
participantName: string,
state: string
) => void
) => {
registeredHandler = handler;
}
);
api.onParticipantState(callback);
registeredHandler!({}, 'gc-123', 'Agent1', 'working');
expect(callback).toHaveBeenCalledWith('gc-123', 'Agent1', 'working');
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (
event: unknown,
groupChatId: string,
participantName: string,
state: string
) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (
event: unknown,
groupChatId: string,
participantName: string,
state: string
) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onParticipantState(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'groupChat:participantState',
registeredHandler!
);
});
});
describe('onModeratorSessionIdChanged', () => {
it('should register and handle moderator session id changed events', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, sessionId: string) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, sessionId: string) => void
) => {
registeredHandler = handler;
}
);
api.onModeratorSessionIdChanged(callback);
registeredHandler!({}, 'gc-123', 'new-session-id');
expect(callback).toHaveBeenCalledWith('gc-123', 'new-session-id');
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, groupChatId: string, sessionId: string) => void;
mockOn.mockImplementation(
(
_channel: string,
handler: (event: unknown, groupChatId: string, sessionId: string) => void
) => {
registeredHandler = handler;
}
);
const cleanup = api.onModeratorSessionIdChanged(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'groupChat:moderatorSessionIdChanged',
registeredHandler!
);
});
});
});
});

View File

@@ -0,0 +1,176 @@
/**
* Tests for leaderboard preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createLeaderboardApi } from '../../../main/preload/leaderboard';
describe('Leaderboard Preload API', () => {
let api: ReturnType<typeof createLeaderboardApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createLeaderboardApi();
});
describe('getInstallationId', () => {
it('should invoke leaderboard:getInstallationId', async () => {
mockInvoke.mockResolvedValue('test-installation-id');
const result = await api.getInstallationId();
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:getInstallationId');
expect(result).toBe('test-installation-id');
});
it('should return null when no installation ID', async () => {
mockInvoke.mockResolvedValue(null);
const result = await api.getInstallationId();
expect(result).toBeNull();
});
});
describe('submit', () => {
it('should invoke leaderboard:submit with data', async () => {
const submitData = {
email: 'test@example.com',
displayName: 'Test User',
badgeLevel: 1,
badgeName: 'Bronze',
cumulativeTimeMs: 10000,
totalRuns: 5,
};
mockInvoke.mockResolvedValue({ success: true, message: 'Submitted' });
const result = await api.submit(submitData);
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:submit', submitData);
expect(result.success).toBe(true);
});
});
describe('pollAuthStatus', () => {
it('should invoke leaderboard:pollAuthStatus with clientToken', async () => {
mockInvoke.mockResolvedValue({ status: 'confirmed', authToken: 'new-token' });
const result = await api.pollAuthStatus('client-token');
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:pollAuthStatus', 'client-token');
expect(result.status).toBe('confirmed');
expect(result.authToken).toBe('new-token');
});
it('should handle pending status', async () => {
mockInvoke.mockResolvedValue({ status: 'pending' });
const result = await api.pollAuthStatus('client-token');
expect(result.status).toBe('pending');
});
});
describe('resendConfirmation', () => {
it('should invoke leaderboard:resendConfirmation with email and clientToken', async () => {
mockInvoke.mockResolvedValue({ success: true, message: 'Email sent' });
const result = await api.resendConfirmation({
email: 'test@example.com',
clientToken: 'token',
});
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:resendConfirmation', {
email: 'test@example.com',
clientToken: 'token',
});
expect(result.success).toBe(true);
});
});
describe('get', () => {
it('should invoke leaderboard:get without options', async () => {
mockInvoke.mockResolvedValue({ success: true, entries: [] });
const result = await api.get();
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:get', undefined);
expect(result.success).toBe(true);
});
it('should invoke leaderboard:get with limit option', async () => {
const mockEntries = [
{ rank: 1, displayName: 'User', badgeLevel: 5, cumulativeTimeMs: 100000 },
];
mockInvoke.mockResolvedValue({ success: true, entries: mockEntries });
const result = await api.get({ limit: 10 });
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:get', { limit: 10 });
expect(result.entries).toEqual(mockEntries);
});
});
describe('getLongestRuns', () => {
it('should invoke leaderboard:getLongestRuns', async () => {
const mockEntries = [
{ rank: 1, displayName: 'User', longestRunMs: 50000, runDate: '2024-01-01' },
];
mockInvoke.mockResolvedValue({ success: true, entries: mockEntries });
const result = await api.getLongestRuns({ limit: 5 });
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:getLongestRuns', { limit: 5 });
expect(result.entries).toEqual(mockEntries);
});
});
describe('sync', () => {
it('should invoke leaderboard:sync with email and authToken', async () => {
const mockData = {
displayName: 'Test User',
badgeLevel: 3,
badgeName: 'Gold',
cumulativeTimeMs: 50000,
totalRuns: 25,
longestRunMs: 5000,
longestRunDate: '2024-01-01',
keyboardLevel: 2,
coveragePercent: 75,
ranking: {
cumulative: { rank: 5, total: 100 },
longestRun: { rank: 10, total: 100 },
},
};
mockInvoke.mockResolvedValue({ success: true, found: true, data: mockData });
const result = await api.sync({ email: 'test@example.com', authToken: 'token' });
expect(mockInvoke).toHaveBeenCalledWith('leaderboard:sync', {
email: 'test@example.com',
authToken: 'token',
});
expect(result.success).toBe(true);
expect(result.found).toBe(true);
expect(result.data).toEqual(mockData);
});
it('should handle user not found', async () => {
mockInvoke.mockResolvedValue({ success: true, found: false });
const result = await api.sync({ email: 'test@example.com', authToken: 'token' });
expect(result.found).toBe(false);
});
});
});

View File

@@ -0,0 +1,284 @@
/**
* Tests for logger preload API
*
* Coverage:
* - createLoggerApi: log, getLogs, clearLogs, setLogLevel, getLogLevel, setMaxLogBuffer,
* getMaxLogBuffer, toast, autorun, onNewLog, getLogFilePath, isFileLoggingEnabled, enableFileLogging
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createLoggerApi } from '../../../main/preload/logger';
describe('Logger Preload API', () => {
let api: ReturnType<typeof createLoggerApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createLoggerApi();
});
describe('log', () => {
it('should invoke logger:log with level and message', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.log('info', 'Test message');
expect(mockInvoke).toHaveBeenCalledWith(
'logger:log',
'info',
'Test message',
undefined,
undefined
);
});
it('should invoke logger:log with context and data', async () => {
mockInvoke.mockResolvedValue(undefined);
const data = { key: 'value' };
await api.log('error', 'Error occurred', 'MyContext', data);
expect(mockInvoke).toHaveBeenCalledWith(
'logger:log',
'error',
'Error occurred',
'MyContext',
data
);
});
});
describe('getLogs', () => {
it('should invoke logger:getLogs without filter', async () => {
const logs = [{ level: 'info', message: 'Test', timestamp: Date.now() }];
mockInvoke.mockResolvedValue(logs);
const result = await api.getLogs();
expect(mockInvoke).toHaveBeenCalledWith('logger:getLogs', undefined);
expect(result).toEqual(logs);
});
it('should invoke logger:getLogs with filter', async () => {
mockInvoke.mockResolvedValue([]);
const filter = { level: 'error' as const, context: 'Test', limit: 100 };
await api.getLogs(filter);
expect(mockInvoke).toHaveBeenCalledWith('logger:getLogs', filter);
});
});
describe('clearLogs', () => {
it('should invoke logger:clearLogs', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.clearLogs();
expect(mockInvoke).toHaveBeenCalledWith('logger:clearLogs');
});
});
describe('setLogLevel', () => {
it('should invoke logger:setLogLevel', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.setLogLevel('debug');
expect(mockInvoke).toHaveBeenCalledWith('logger:setLogLevel', 'debug');
});
});
describe('getLogLevel', () => {
it('should invoke logger:getLogLevel', async () => {
mockInvoke.mockResolvedValue('info');
const result = await api.getLogLevel();
expect(mockInvoke).toHaveBeenCalledWith('logger:getLogLevel');
expect(result).toBe('info');
});
});
describe('setMaxLogBuffer', () => {
it('should invoke logger:setMaxLogBuffer', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.setMaxLogBuffer(500);
expect(mockInvoke).toHaveBeenCalledWith('logger:setMaxLogBuffer', 500);
});
});
describe('getMaxLogBuffer', () => {
it('should invoke logger:getMaxLogBuffer', async () => {
mockInvoke.mockResolvedValue(1000);
const result = await api.getMaxLogBuffer();
expect(mockInvoke).toHaveBeenCalledWith('logger:getMaxLogBuffer');
expect(result).toBe(1000);
});
});
describe('toast', () => {
it('should invoke logger:log with toast level', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.toast('Notification title');
expect(mockInvoke).toHaveBeenCalledWith(
'logger:log',
'toast',
'Notification title',
'Toast',
undefined
);
});
it('should invoke with data', async () => {
mockInvoke.mockResolvedValue(undefined);
const data = { action: 'clicked' };
await api.toast('Notification', data);
expect(mockInvoke).toHaveBeenCalledWith('logger:log', 'toast', 'Notification', 'Toast', data);
});
});
describe('autorun', () => {
it('should invoke logger:log with autorun level', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.autorun('Task started');
expect(mockInvoke).toHaveBeenCalledWith(
'logger:log',
'autorun',
'Task started',
'AutoRun',
undefined
);
});
it('should invoke with custom context', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.autorun('Task completed', 'Playbook');
expect(mockInvoke).toHaveBeenCalledWith(
'logger:log',
'autorun',
'Task completed',
'Playbook',
undefined
);
});
it('should invoke with data', async () => {
mockInvoke.mockResolvedValue(undefined);
const data = { taskIndex: 5 };
await api.autorun('Processing', 'AutoRun', data);
expect(mockInvoke).toHaveBeenCalledWith(
'logger:log',
'autorun',
'Processing',
'AutoRun',
data
);
});
});
describe('onNewLog', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onNewLog(callback);
expect(mockOn).toHaveBeenCalledWith('logger:newLog', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, log: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, log: unknown) => void) => {
registeredHandler = handler;
}
);
api.onNewLog(callback);
const logEntry = { level: 'info', message: 'Test', timestamp: Date.now() };
registeredHandler!({}, logEntry);
expect(callback).toHaveBeenCalledWith(logEntry);
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, log: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, log: unknown) => void) => {
registeredHandler = handler;
}
);
const cleanup = api.onNewLog(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith('logger:newLog', registeredHandler!);
});
});
describe('getLogFilePath', () => {
it('should invoke logger:getLogFilePath', async () => {
mockInvoke.mockResolvedValue('/path/to/logs/maestro.log');
const result = await api.getLogFilePath();
expect(mockInvoke).toHaveBeenCalledWith('logger:getLogFilePath');
expect(result).toBe('/path/to/logs/maestro.log');
});
});
describe('isFileLoggingEnabled', () => {
it('should invoke logger:isFileLoggingEnabled', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.isFileLoggingEnabled();
expect(mockInvoke).toHaveBeenCalledWith('logger:isFileLoggingEnabled');
expect(result).toBe(true);
});
});
describe('enableFileLogging', () => {
it('should invoke logger:enableFileLogging', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.enableFileLogging();
expect(mockInvoke).toHaveBeenCalledWith('logger:enableFileLogging');
});
});
});

View File

@@ -0,0 +1,125 @@
/**
* Tests for notifications preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createNotificationApi } from '../../../main/preload/notifications';
describe('Notification Preload API', () => {
let api: ReturnType<typeof createNotificationApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createNotificationApi();
});
describe('show', () => {
it('should invoke notification:show with title and body', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.show('Test Title', 'Test Body');
expect(mockInvoke).toHaveBeenCalledWith('notification:show', 'Test Title', 'Test Body');
expect(result).toEqual({ success: true });
});
it('should handle errors', async () => {
mockInvoke.mockResolvedValue({ success: false, error: 'Failed to show notification' });
const result = await api.show('Title', 'Body');
expect(result.success).toBe(false);
expect(result.error).toBe('Failed to show notification');
});
});
describe('speak', () => {
it('should invoke notification:speak with text', async () => {
mockInvoke.mockResolvedValue({ success: true, ttsId: 123 });
const result = await api.speak('Hello world');
expect(mockInvoke).toHaveBeenCalledWith('notification:speak', 'Hello world', undefined);
expect(result).toEqual({ success: true, ttsId: 123 });
});
it('should invoke notification:speak with custom command', async () => {
mockInvoke.mockResolvedValue({ success: true, ttsId: 456 });
const result = await api.speak('Hello', 'espeak');
expect(mockInvoke).toHaveBeenCalledWith('notification:speak', 'Hello', 'espeak');
expect(result.ttsId).toBe(456);
});
});
describe('stopSpeak', () => {
it('should invoke notification:stopSpeak with ttsId', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.stopSpeak(123);
expect(mockInvoke).toHaveBeenCalledWith('notification:stopSpeak', 123);
expect(result.success).toBe(true);
});
});
describe('onTtsCompleted', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onTtsCompleted(callback);
expect(mockOn).toHaveBeenCalledWith('tts:completed', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, ttsId: number) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, ttsId: number) => void) => {
registeredHandler = handler;
}
);
api.onTtsCompleted(callback);
// Simulate receiving the event
registeredHandler!({}, 789);
expect(callback).toHaveBeenCalledWith(789);
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, ttsId: number) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, ttsId: number) => void) => {
registeredHandler = handler;
}
);
const cleanup = api.onTtsCompleted(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith('tts:completed', registeredHandler!);
});
});
});

View File

@@ -0,0 +1,298 @@
/**
* Tests for process preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
const mockSend = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
send: (...args: unknown[]) => mockSend(...args),
},
}));
import { createProcessApi, type ProcessConfig } from '../../../main/preload/process';
describe('Process Preload API', () => {
let api: ReturnType<typeof createProcessApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createProcessApi();
});
describe('spawn', () => {
it('should invoke process:spawn with config', async () => {
const config: ProcessConfig = {
sessionId: 'session-123',
toolType: 'claude-code',
cwd: '/home/user/project',
command: 'claude',
args: ['--json'],
};
mockInvoke.mockResolvedValue({ pid: 1234, success: true });
const result = await api.spawn(config);
expect(mockInvoke).toHaveBeenCalledWith('process:spawn', config);
expect(result.pid).toBe(1234);
expect(result.success).toBe(true);
});
it('should handle SSH remote response', async () => {
const config: ProcessConfig = {
sessionId: 'session-123',
toolType: 'claude-code',
cwd: '/home/user/project',
command: 'claude',
args: [],
};
mockInvoke.mockResolvedValue({
pid: 1234,
success: true,
sshRemote: { id: 'remote-1', name: 'My Server', host: 'example.com' },
});
const result = await api.spawn(config);
expect(result.sshRemote).toEqual({ id: 'remote-1', name: 'My Server', host: 'example.com' });
});
});
describe('write', () => {
it('should invoke process:write with sessionId and data', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.write('session-123', 'Hello');
expect(mockInvoke).toHaveBeenCalledWith('process:write', 'session-123', 'Hello');
expect(result).toBe(true);
});
});
describe('interrupt', () => {
it('should invoke process:interrupt with sessionId', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.interrupt('session-123');
expect(mockInvoke).toHaveBeenCalledWith('process:interrupt', 'session-123');
expect(result).toBe(true);
});
});
describe('kill', () => {
it('should invoke process:kill with sessionId', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.kill('session-123');
expect(mockInvoke).toHaveBeenCalledWith('process:kill', 'session-123');
expect(result).toBe(true);
});
});
describe('resize', () => {
it('should invoke process:resize with sessionId, cols, and rows', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.resize('session-123', 120, 40);
expect(mockInvoke).toHaveBeenCalledWith('process:resize', 'session-123', 120, 40);
expect(result).toBe(true);
});
});
describe('runCommand', () => {
it('should invoke process:runCommand with config', async () => {
const config = {
sessionId: 'session-123',
command: 'ls -la',
cwd: '/home/user',
shell: '/bin/bash',
};
mockInvoke.mockResolvedValue({ exitCode: 0 });
const result = await api.runCommand(config);
expect(mockInvoke).toHaveBeenCalledWith('process:runCommand', config);
expect(result.exitCode).toBe(0);
});
it('should handle SSH remote config', async () => {
const config = {
sessionId: 'session-123',
command: 'ls -la',
cwd: '/home/user',
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '/remote/path',
},
};
mockInvoke.mockResolvedValue({ exitCode: 0 });
await api.runCommand(config);
expect(mockInvoke).toHaveBeenCalledWith('process:runCommand', config);
});
});
describe('getActiveProcesses', () => {
it('should invoke process:getActiveProcesses', async () => {
const mockProcesses = [
{
sessionId: 'session-123',
toolType: 'claude-code',
pid: 1234,
cwd: '/home/user',
isTerminal: false,
isBatchMode: false,
startTime: Date.now(),
},
];
mockInvoke.mockResolvedValue(mockProcesses);
const result = await api.getActiveProcesses();
expect(mockInvoke).toHaveBeenCalledWith('process:getActiveProcesses');
expect(result).toEqual(mockProcesses);
});
});
describe('onData', () => {
it('should register event listener for process:data', () => {
const callback = vi.fn();
const cleanup = api.onData(callback);
expect(mockOn).toHaveBeenCalledWith('process:data', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback with sessionId and data', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, sessionId: string, data: string) => void;
mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => {
if (channel === 'process:data') {
registeredHandler = handler;
}
});
api.onData(callback);
registeredHandler!({}, 'session-123', 'output data');
expect(callback).toHaveBeenCalledWith('session-123', 'output data');
});
});
describe('onExit', () => {
it('should register event listener for process:exit', () => {
const callback = vi.fn();
const cleanup = api.onExit(callback);
expect(mockOn).toHaveBeenCalledWith('process:exit', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
});
describe('onUsage', () => {
it('should register event listener for process:usage', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, sessionId: string, usageStats: unknown) => void;
mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => {
if (channel === 'process:usage') {
registeredHandler = handler;
}
});
api.onUsage(callback);
const usageStats = {
inputTokens: 100,
outputTokens: 200,
cacheReadInputTokens: 50,
cacheCreationInputTokens: 25,
totalCostUsd: 0.01,
contextWindow: 100000,
};
registeredHandler!({}, 'session-123', usageStats);
expect(callback).toHaveBeenCalledWith('session-123', usageStats);
});
});
describe('onAgentError', () => {
it('should register event listener for agent:error', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, sessionId: string, error: unknown) => void;
mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => {
if (channel === 'agent:error') {
registeredHandler = handler;
}
});
api.onAgentError(callback);
const error = {
type: 'auth_expired',
message: 'Authentication expired',
recoverable: true,
agentId: 'claude-code',
timestamp: Date.now(),
};
registeredHandler!({}, 'session-123', error);
expect(callback).toHaveBeenCalledWith('session-123', error);
});
});
describe('sendRemoteNewTabResponse', () => {
it('should send response via ipcRenderer.send', () => {
api.sendRemoteNewTabResponse('response-channel', { tabId: 'tab-123' });
expect(mockSend).toHaveBeenCalledWith('response-channel', { tabId: 'tab-123' });
});
it('should send null result', () => {
api.sendRemoteNewTabResponse('response-channel', null);
expect(mockSend).toHaveBeenCalledWith('response-channel', null);
});
});
describe('onRemoteCommand', () => {
it('should register listener and invoke callback with all parameters', () => {
const callback = vi.fn();
let registeredHandler: (
event: unknown,
sessionId: string,
command: string,
inputMode?: 'ai' | 'terminal'
) => void;
mockOn.mockImplementation((channel: string, handler: typeof registeredHandler) => {
if (channel === 'remote:executeCommand') {
registeredHandler = handler;
}
});
api.onRemoteCommand(callback);
registeredHandler!({}, 'session-123', 'test command', 'ai');
expect(callback).toHaveBeenCalledWith('session-123', 'test command', 'ai');
});
});
});

View File

@@ -0,0 +1,448 @@
/**
* Tests for sessions preload API
*
* Coverage:
* - createClaudeApi (deprecated): listSessions, listSessionsPaginated, getProjectStats,
* readSessionMessages, searchSessions, deleteMessagePair
* - createAgentSessionsApi: list, listPaginated, read, search, getPath, deleteMessagePair,
* hasStorage, getAvailableStorages, getGlobalStats, getAllNamedSessions, onGlobalStatsUpdate,
* registerSessionOrigin, updateSessionName, getOrigins, setSessionName, setSessionStarred
*
* Note: createClaudeApi is deprecated but still tested for backwards compatibility.
* These tests ensure the deprecation warnings are logged correctly.
*/
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
// Mock console.warn for deprecation warnings
const mockConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createClaudeApi, createAgentSessionsApi } from '../../../main/preload/sessions';
describe('Sessions Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConsoleWarn.mockClear();
});
afterAll(() => {
mockConsoleWarn.mockRestore();
});
describe('createClaudeApi (deprecated)', () => {
let api: ReturnType<typeof createClaudeApi>;
beforeEach(() => {
api = createClaudeApi();
});
describe('listSessions', () => {
it('should invoke claude:listSessions and log deprecation warning', async () => {
mockInvoke.mockResolvedValue([]);
await api.listSessions('/project');
expect(mockInvoke).toHaveBeenCalledWith('claude:listSessions', '/project');
expect(mockConsoleWarn).toHaveBeenCalledWith(expect.stringContaining('deprecated'));
});
});
describe('listSessionsPaginated', () => {
it('should invoke claude:listSessionsPaginated', async () => {
mockInvoke.mockResolvedValue({ sessions: [], cursor: null });
await api.listSessionsPaginated('/project', { limit: 10 });
expect(mockInvoke).toHaveBeenCalledWith('claude:listSessionsPaginated', '/project', {
limit: 10,
});
});
});
describe('getProjectStats', () => {
it('should invoke claude:getProjectStats', async () => {
mockInvoke.mockResolvedValue({ totalSessions: 5 });
await api.getProjectStats('/project');
expect(mockInvoke).toHaveBeenCalledWith('claude:getProjectStats', '/project');
});
});
describe('readSessionMessages', () => {
it('should invoke claude:readSessionMessages', async () => {
mockInvoke.mockResolvedValue({ messages: [] });
await api.readSessionMessages('/project', 'session-123', { limit: 50 });
expect(mockInvoke).toHaveBeenCalledWith(
'claude:readSessionMessages',
'/project',
'session-123',
{ limit: 50 }
);
});
});
describe('searchSessions', () => {
it('should invoke claude:searchSessions', async () => {
mockInvoke.mockResolvedValue([]);
await api.searchSessions('/project', 'query', 'all');
expect(mockInvoke).toHaveBeenCalledWith(
'claude:searchSessions',
'/project',
'query',
'all'
);
});
});
describe('deleteMessagePair', () => {
it('should invoke claude:deleteMessagePair', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.deleteMessagePair('/project', 'session-123', 'uuid-456', 'fallback');
expect(mockInvoke).toHaveBeenCalledWith(
'claude:deleteMessagePair',
'/project',
'session-123',
'uuid-456',
'fallback'
);
});
});
});
describe('createAgentSessionsApi', () => {
let api: ReturnType<typeof createAgentSessionsApi>;
beforeEach(() => {
api = createAgentSessionsApi();
});
describe('list', () => {
it('should invoke agentSessions:list', async () => {
mockInvoke.mockResolvedValue([]);
await api.list('claude-code', '/project');
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:list',
'claude-code',
'/project',
undefined
);
});
it('should invoke with sshRemoteId', async () => {
mockInvoke.mockResolvedValue([]);
await api.list('claude-code', '/project', 'ssh-remote-1');
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:list',
'claude-code',
'/project',
'ssh-remote-1'
);
});
});
describe('listPaginated', () => {
it('should invoke agentSessions:listPaginated', async () => {
mockInvoke.mockResolvedValue({ sessions: [], cursor: null });
await api.listPaginated('claude-code', '/project', { limit: 20 });
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:listPaginated',
'claude-code',
'/project',
{ limit: 20 },
undefined
);
});
});
describe('read', () => {
it('should invoke agentSessions:read', async () => {
mockInvoke.mockResolvedValue({ messages: [] });
await api.read('claude-code', '/project', 'session-123', { offset: 0, limit: 50 });
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:read',
'claude-code',
'/project',
'session-123',
{ offset: 0, limit: 50 },
undefined
);
});
});
describe('search', () => {
it('should invoke agentSessions:search', async () => {
mockInvoke.mockResolvedValue([]);
await api.search('claude-code', '/project', 'search query', 'title');
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:search',
'claude-code',
'/project',
'search query',
'title',
undefined
);
});
});
describe('getPath', () => {
it('should invoke agentSessions:getPath', async () => {
mockInvoke.mockResolvedValue('/path/to/session.json');
const result = await api.getPath('claude-code', '/project', 'session-123');
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:getPath',
'claude-code',
'/project',
'session-123',
undefined
);
expect(result).toBe('/path/to/session.json');
});
});
describe('deleteMessagePair', () => {
it('should invoke agentSessions:deleteMessagePair', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.deleteMessagePair('claude-code', '/project', 'session-123', 'uuid-456');
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:deleteMessagePair',
'claude-code',
'/project',
'session-123',
'uuid-456',
undefined
);
});
});
describe('hasStorage', () => {
it('should invoke agentSessions:hasStorage', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.hasStorage('claude-code');
expect(mockInvoke).toHaveBeenCalledWith('agentSessions:hasStorage', 'claude-code');
expect(result).toBe(true);
});
});
describe('getAvailableStorages', () => {
it('should invoke agentSessions:getAvailableStorages', async () => {
mockInvoke.mockResolvedValue(['claude-code', 'opencode']);
const result = await api.getAvailableStorages();
expect(mockInvoke).toHaveBeenCalledWith('agentSessions:getAvailableStorages');
expect(result).toEqual(['claude-code', 'opencode']);
});
});
describe('getGlobalStats', () => {
it('should invoke agentSessions:getGlobalStats', async () => {
const stats = { totalSessions: 100, totalMessages: 1000 };
mockInvoke.mockResolvedValue(stats);
const result = await api.getGlobalStats();
expect(mockInvoke).toHaveBeenCalledWith('agentSessions:getGlobalStats');
expect(result).toEqual(stats);
});
});
describe('getAllNamedSessions', () => {
it('should invoke agentSessions:getAllNamedSessions', async () => {
const sessions = [
{
agentId: 'claude-code',
agentSessionId: '123',
projectPath: '/project',
sessionName: 'Test',
},
];
mockInvoke.mockResolvedValue(sessions);
const result = await api.getAllNamedSessions();
expect(mockInvoke).toHaveBeenCalledWith('agentSessions:getAllNamedSessions');
expect(result).toEqual(sessions);
});
});
describe('onGlobalStatsUpdate', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onGlobalStatsUpdate(callback);
expect(mockOn).toHaveBeenCalledWith(
'agentSessions:globalStatsUpdate',
expect.any(Function)
);
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, stats: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, stats: unknown) => void) => {
registeredHandler = handler;
}
);
api.onGlobalStatsUpdate(callback);
const stats = { totalSessions: 50 };
registeredHandler!({}, stats);
expect(callback).toHaveBeenCalledWith(stats);
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, stats: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, stats: unknown) => void) => {
registeredHandler = handler;
}
);
const cleanup = api.onGlobalStatsUpdate(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith(
'agentSessions:globalStatsUpdate',
registeredHandler!
);
});
});
describe('registerSessionOrigin', () => {
it('should invoke claude:registerSessionOrigin', async () => {
mockInvoke.mockResolvedValue(true);
await api.registerSessionOrigin('/project', 'agent-session-123', 'user', 'My Session');
expect(mockInvoke).toHaveBeenCalledWith(
'claude:registerSessionOrigin',
'/project',
'agent-session-123',
'user',
'My Session'
);
});
});
describe('updateSessionName', () => {
it('should invoke claude:updateSessionName', async () => {
mockInvoke.mockResolvedValue(true);
await api.updateSessionName('/project', 'agent-session-123', 'New Name');
expect(mockInvoke).toHaveBeenCalledWith(
'claude:updateSessionName',
'/project',
'agent-session-123',
'New Name'
);
});
});
describe('getOrigins', () => {
it('should invoke agentSessions:getOrigins', async () => {
const origins = { 'session-1': { origin: 'user', sessionName: 'Test' } };
mockInvoke.mockResolvedValue(origins);
const result = await api.getOrigins('claude-code', '/project');
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:getOrigins',
'claude-code',
'/project'
);
expect(result).toEqual(origins);
});
});
describe('setSessionName', () => {
it('should invoke agentSessions:setSessionName', async () => {
mockInvoke.mockResolvedValue(true);
await api.setSessionName('claude-code', '/project', 'session-123', 'New Name');
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:setSessionName',
'claude-code',
'/project',
'session-123',
'New Name'
);
});
it('should handle null to clear name', async () => {
mockInvoke.mockResolvedValue(true);
await api.setSessionName('claude-code', '/project', 'session-123', null);
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:setSessionName',
'claude-code',
'/project',
'session-123',
null
);
});
});
describe('setSessionStarred', () => {
it('should invoke agentSessions:setSessionStarred', async () => {
mockInvoke.mockResolvedValue(true);
await api.setSessionStarred('claude-code', '/project', 'session-123', true);
expect(mockInvoke).toHaveBeenCalledWith(
'agentSessions:setSessionStarred',
'claude-code',
'/project',
'session-123',
true
);
});
});
});
});

View File

@@ -0,0 +1,200 @@
/**
* Tests for settings preload API
*
* Coverage:
* - createSettingsApi: get, set, getAll
* - createSessionsApi: getAll, setAll
* - createGroupsApi: getAll, setAll
* - createAgentErrorApi: clearError, retryAfterError
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import {
createSettingsApi,
createSessionsApi,
createGroupsApi,
createAgentErrorApi,
} from '../../../main/preload/settings';
describe('Settings Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createSettingsApi', () => {
let api: ReturnType<typeof createSettingsApi>;
beforeEach(() => {
api = createSettingsApi();
});
describe('get', () => {
it('should invoke settings:get with key', async () => {
mockInvoke.mockResolvedValue('test-value');
const result = await api.get('theme');
expect(mockInvoke).toHaveBeenCalledWith('settings:get', 'theme');
expect(result).toBe('test-value');
});
it('should return undefined for non-existent key', async () => {
mockInvoke.mockResolvedValue(undefined);
const result = await api.get('non-existent');
expect(result).toBeUndefined();
});
});
describe('set', () => {
it('should invoke settings:set with key and value and return result', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.set('theme', 'dark');
expect(mockInvoke).toHaveBeenCalledWith('settings:set', 'theme', 'dark');
expect(result).toBe(true);
});
it('should handle complex values', async () => {
mockInvoke.mockResolvedValue(true);
const complexValue = { nested: { key: 'value' }, array: [1, 2, 3] };
const result = await api.set('config', complexValue);
expect(mockInvoke).toHaveBeenCalledWith('settings:set', 'config', complexValue);
expect(result).toBe(true);
});
});
describe('getAll', () => {
it('should invoke settings:getAll', async () => {
const allSettings = { theme: 'dark', fontSize: 14 };
mockInvoke.mockResolvedValue(allSettings);
const result = await api.getAll();
expect(mockInvoke).toHaveBeenCalledWith('settings:getAll');
expect(result).toEqual(allSettings);
});
});
});
describe('createSessionsApi', () => {
let api: ReturnType<typeof createSessionsApi>;
beforeEach(() => {
api = createSessionsApi();
});
describe('getAll', () => {
it('should invoke sessions:getAll', async () => {
const sessions = [{ id: '1', name: 'Session 1' }];
mockInvoke.mockResolvedValue(sessions);
const result = await api.getAll();
expect(mockInvoke).toHaveBeenCalledWith('sessions:getAll');
expect(result).toEqual(sessions);
});
});
describe('setAll', () => {
it('should invoke sessions:setAll with sessions array and return result', async () => {
const sessions = [
{ id: '1', name: 'Session 1' },
{ id: '2', name: 'Session 2' },
];
mockInvoke.mockResolvedValue(true);
const result = await api.setAll(sessions);
expect(mockInvoke).toHaveBeenCalledWith('sessions:setAll', sessions);
expect(result).toBe(true);
});
});
});
describe('createGroupsApi', () => {
let api: ReturnType<typeof createGroupsApi>;
beforeEach(() => {
api = createGroupsApi();
});
describe('getAll', () => {
it('should invoke groups:getAll', async () => {
const groups = [{ id: '1', name: 'Group 1' }];
mockInvoke.mockResolvedValue(groups);
const result = await api.getAll();
expect(mockInvoke).toHaveBeenCalledWith('groups:getAll');
expect(result).toEqual(groups);
});
});
describe('setAll', () => {
it('should invoke groups:setAll with groups array and return result', async () => {
const groups = [{ id: '1', name: 'Group 1' }];
mockInvoke.mockResolvedValue(true);
const result = await api.setAll(groups);
expect(mockInvoke).toHaveBeenCalledWith('groups:setAll', groups);
expect(result).toBe(true);
});
});
});
describe('createAgentErrorApi', () => {
let api: ReturnType<typeof createAgentErrorApi>;
beforeEach(() => {
api = createAgentErrorApi();
});
describe('clearError', () => {
it('should invoke agent:clearError with sessionId and return result', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.clearError('session-123');
expect(mockInvoke).toHaveBeenCalledWith('agent:clearError', 'session-123');
expect(result).toBe(true);
});
});
describe('retryAfterError', () => {
it('should invoke agent:retryAfterError with sessionId and undefined options', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.retryAfterError('session-123');
expect(mockInvoke).toHaveBeenCalledWith('agent:retryAfterError', 'session-123', undefined);
expect(result).toBe(true);
});
it('should invoke agent:retryAfterError with options', async () => {
mockInvoke.mockResolvedValue(true);
const options = { prompt: 'retry prompt', newSession: true };
const result = await api.retryAfterError('session-123', options);
expect(mockInvoke).toHaveBeenCalledWith('agent:retryAfterError', 'session-123', options);
expect(result).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,211 @@
/**
* Tests for sshRemote preload API
*
* Coverage:
* - createSshRemoteApi: saveConfig, deleteConfig, getConfigs, getDefaultId, setDefaultId,
* test, getSshConfigHosts
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createSshRemoteApi, type SshRemoteConfig } from '../../../main/preload/sshRemote';
describe('SSH Remote Preload API', () => {
let api: ReturnType<typeof createSshRemoteApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createSshRemoteApi();
});
describe('saveConfig', () => {
it('should invoke ssh-remote:saveConfig with config', async () => {
mockInvoke.mockResolvedValue({ id: 'config-123' });
const config = {
name: 'My Server',
host: 'server.example.com',
port: 22,
username: 'user',
privateKeyPath: '/path/to/key',
enabled: true,
};
const result = await api.saveConfig(config);
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:saveConfig', config);
expect(result).toEqual({ id: 'config-123' });
});
it('should handle updating existing config', async () => {
mockInvoke.mockResolvedValue({ id: 'existing-id' });
const config = {
id: 'existing-id',
name: 'Updated Server',
};
await api.saveConfig(config);
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:saveConfig', config);
});
});
describe('deleteConfig', () => {
it('should invoke ssh-remote:deleteConfig with id', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.deleteConfig('config-123');
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:deleteConfig', 'config-123');
expect(result).toBe(true);
});
});
describe('getConfigs', () => {
it('should invoke ssh-remote:getConfigs', async () => {
const configs: SshRemoteConfig[] = [
{
id: '1',
name: 'Server 1',
host: 'server1.example.com',
port: 22,
username: 'user1',
privateKeyPath: '/path/to/key1',
enabled: true,
},
{
id: '2',
name: 'Server 2',
host: 'server2.example.com',
port: 2222,
username: 'user2',
privateKeyPath: '/path/to/key2',
enabled: false,
},
];
mockInvoke.mockResolvedValue(configs);
const result = await api.getConfigs();
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:getConfigs');
expect(result).toEqual(configs);
});
});
describe('getDefaultId', () => {
it('should invoke ssh-remote:getDefaultId', async () => {
mockInvoke.mockResolvedValue('default-config-id');
const result = await api.getDefaultId();
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:getDefaultId');
expect(result).toBe('default-config-id');
});
it('should return null when no default', async () => {
mockInvoke.mockResolvedValue(null);
const result = await api.getDefaultId();
expect(result).toBeNull();
});
});
describe('setDefaultId', () => {
it('should invoke ssh-remote:setDefaultId with id', async () => {
mockInvoke.mockResolvedValue(true);
await api.setDefaultId('config-123');
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:setDefaultId', 'config-123');
});
it('should handle null to clear default', async () => {
mockInvoke.mockResolvedValue(true);
await api.setDefaultId(null);
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:setDefaultId', null);
});
});
describe('test', () => {
it('should invoke ssh-remote:test with config id', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.test('config-123');
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:test', 'config-123', undefined);
expect(result).toEqual({ success: true });
});
it('should invoke ssh-remote:test with config object', async () => {
mockInvoke.mockResolvedValue({ success: true });
const config: SshRemoteConfig = {
id: 'test',
name: 'Test Server',
host: 'test.example.com',
port: 22,
username: 'testuser',
privateKeyPath: '/path/to/key',
enabled: true,
};
const result = await api.test(config);
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:test', config, undefined);
expect(result).toEqual({ success: true });
});
it('should invoke ssh-remote:test with agent command', async () => {
mockInvoke.mockResolvedValue({ success: true, agentVersion: '1.0.0' });
const result = await api.test('config-123', 'claude');
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:test', 'config-123', 'claude');
expect(result).toEqual({ success: true, agentVersion: '1.0.0' });
});
});
describe('getSshConfigHosts', () => {
it('should invoke ssh-remote:getSshConfigHosts', async () => {
const response = {
success: true,
hosts: [
{ host: 'server1', hostName: 'server1.example.com', user: 'user1' },
{ host: 'server2', hostName: 'server2.example.com', port: 2222 },
],
configPath: '/home/user/.ssh/config',
};
mockInvoke.mockResolvedValue(response);
const result = await api.getSshConfigHosts();
expect(mockInvoke).toHaveBeenCalledWith('ssh-remote:getSshConfigHosts');
expect(result).toEqual(response);
});
it('should handle errors', async () => {
const response = {
success: false,
hosts: [],
error: 'Config file not found',
configPath: '/home/user/.ssh/config',
};
mockInvoke.mockResolvedValue(response);
const result = await api.getSshConfigHosts();
expect(result.success).toBe(false);
expect(result.error).toBe('Config file not found');
});
});
});

View File

@@ -0,0 +1,368 @@
/**
* Tests for stats preload API
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
},
}));
import { createStatsApi } from '../../../main/preload/stats';
describe('Stats Preload API', () => {
let api: ReturnType<typeof createStatsApi>;
beforeEach(() => {
vi.clearAllMocks();
api = createStatsApi();
});
describe('recordQuery', () => {
it('should invoke stats:record-query', async () => {
mockInvoke.mockResolvedValue('query-123');
const event = {
sessionId: 'session-1',
agentType: 'claude-code',
source: 'user' as const,
startTime: Date.now(),
duration: 5000,
projectPath: '/project',
};
const result = await api.recordQuery(event);
expect(mockInvoke).toHaveBeenCalledWith('stats:record-query', event);
expect(result).toBe('query-123');
});
});
describe('startAutoRun', () => {
it('should invoke stats:start-autorun', async () => {
mockInvoke.mockResolvedValue('autorun-123');
const session = {
sessionId: 'session-1',
agentType: 'claude-code',
documentPath: '/project/.maestro/tasks.md',
startTime: Date.now(),
tasksTotal: 10,
projectPath: '/project',
};
const result = await api.startAutoRun(session);
expect(mockInvoke).toHaveBeenCalledWith('stats:start-autorun', session);
expect(result).toBe('autorun-123');
});
});
describe('endAutoRun', () => {
it('should invoke stats:end-autorun', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.endAutoRun('autorun-123', 60000, 8);
expect(mockInvoke).toHaveBeenCalledWith('stats:end-autorun', 'autorun-123', 60000, 8);
expect(result).toBe(true);
});
});
describe('recordAutoTask', () => {
it('should invoke stats:record-task', async () => {
mockInvoke.mockResolvedValue('task-123');
const task = {
autoRunSessionId: 'autorun-123',
sessionId: 'session-1',
agentType: 'claude-code',
taskIndex: 3,
taskContent: 'Fix the bug',
startTime: Date.now(),
duration: 10000,
success: true,
};
const result = await api.recordAutoTask(task);
expect(mockInvoke).toHaveBeenCalledWith('stats:record-task', task);
expect(result).toBe('task-123');
});
});
describe('getStats', () => {
it('should invoke stats:get-stats without filters', async () => {
mockInvoke.mockResolvedValue([]);
const result = await api.getStats('day');
expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'day', undefined);
expect(result).toEqual([]);
});
it('should invoke stats:get-stats with filters', async () => {
mockInvoke.mockResolvedValue([]);
const filters = {
agentType: 'claude-code',
source: 'user' as const,
projectPath: '/project',
};
await api.getStats('week', filters);
expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'week', filters);
});
it('should handle all time ranges', async () => {
mockInvoke.mockResolvedValue([]);
await api.getStats('month');
await api.getStats('year');
await api.getStats('all');
expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'month', undefined);
expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'year', undefined);
expect(mockInvoke).toHaveBeenCalledWith('stats:get-stats', 'all', undefined);
});
});
describe('getAutoRunSessions', () => {
it('should invoke stats:get-autorun-sessions', async () => {
const sessions = [
{
id: 'ar-1',
sessionId: 'session-1',
agentType: 'claude-code',
startTime: Date.now(),
duration: 60000,
tasksTotal: 10,
tasksCompleted: 8,
},
];
mockInvoke.mockResolvedValue(sessions);
const result = await api.getAutoRunSessions('day');
expect(mockInvoke).toHaveBeenCalledWith('stats:get-autorun-sessions', 'day');
expect(result).toEqual(sessions);
});
});
describe('getAutoRunTasks', () => {
it('should invoke stats:get-autorun-tasks', async () => {
const tasks = [
{
id: 'task-1',
autoRunSessionId: 'ar-1',
sessionId: 'session-1',
agentType: 'claude-code',
taskIndex: 0,
taskContent: 'Task 1',
startTime: Date.now(),
duration: 5000,
success: true,
},
];
mockInvoke.mockResolvedValue(tasks);
const result = await api.getAutoRunTasks('ar-1');
expect(mockInvoke).toHaveBeenCalledWith('stats:get-autorun-tasks', 'ar-1');
expect(result).toEqual(tasks);
});
});
describe('getAggregation', () => {
it('should invoke stats:get-aggregation', async () => {
const aggregation = {
totalQueries: 100,
totalDuration: 500000,
avgDuration: 5000,
byAgent: {
'claude-code': { count: 80, duration: 400000 },
opencode: { count: 20, duration: 100000 },
},
bySource: { user: 70, auto: 30 },
byDay: [
{ date: '2024-01-01', count: 50, duration: 250000 },
{ date: '2024-01-02', count: 50, duration: 250000 },
],
};
mockInvoke.mockResolvedValue(aggregation);
const result = await api.getAggregation('week');
expect(mockInvoke).toHaveBeenCalledWith('stats:get-aggregation', 'week');
expect(result).toEqual(aggregation);
});
});
describe('exportCsv', () => {
it('should invoke stats:export-csv', async () => {
mockInvoke.mockResolvedValue('/path/to/export.csv');
const result = await api.exportCsv('month');
expect(mockInvoke).toHaveBeenCalledWith('stats:export-csv', 'month');
expect(result).toBe('/path/to/export.csv');
});
});
describe('onStatsUpdate', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onStatsUpdate(callback);
expect(mockOn).toHaveBeenCalledWith('stats:updated', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
api.onStatsUpdate(callback);
registeredHandler!();
expect(callback).toHaveBeenCalled();
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
const cleanup = api.onStatsUpdate(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith('stats:updated', registeredHandler!);
});
});
describe('clearOldData', () => {
it('should invoke stats:clear-old-data', async () => {
const response = {
success: true,
deletedQueryEvents: 100,
deletedAutoRunSessions: 10,
deletedAutoRunTasks: 50,
};
mockInvoke.mockResolvedValue(response);
const result = await api.clearOldData(30);
expect(mockInvoke).toHaveBeenCalledWith('stats:clear-old-data', 30);
expect(result).toEqual(response);
});
it('should handle errors', async () => {
mockInvoke.mockResolvedValue({
success: false,
deletedQueryEvents: 0,
deletedAutoRunSessions: 0,
deletedAutoRunTasks: 0,
error: 'Database error',
});
const result = await api.clearOldData(7);
expect(result.success).toBe(false);
expect(result.error).toBe('Database error');
});
});
describe('getDatabaseSize', () => {
it('should invoke stats:get-database-size', async () => {
mockInvoke.mockResolvedValue(1024000);
const result = await api.getDatabaseSize();
expect(mockInvoke).toHaveBeenCalledWith('stats:get-database-size');
expect(result).toBe(1024000);
});
});
describe('recordSessionCreated', () => {
it('should invoke stats:record-session-created', async () => {
mockInvoke.mockResolvedValue('lifecycle-123');
const event = {
sessionId: 'session-1',
agentType: 'claude-code',
projectPath: '/project',
createdAt: Date.now(),
isRemote: false,
};
const result = await api.recordSessionCreated(event);
expect(mockInvoke).toHaveBeenCalledWith('stats:record-session-created', event);
expect(result).toBe('lifecycle-123');
});
it('should return null for duplicate sessions', async () => {
mockInvoke.mockResolvedValue(null);
const event = {
sessionId: 'session-1',
agentType: 'claude-code',
createdAt: Date.now(),
};
const result = await api.recordSessionCreated(event);
expect(result).toBeNull();
});
});
describe('recordSessionClosed', () => {
it('should invoke stats:record-session-closed', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.recordSessionClosed('session-1', Date.now());
expect(mockInvoke).toHaveBeenCalledWith(
'stats:record-session-closed',
'session-1',
expect.any(Number)
);
expect(result).toBe(true);
});
});
describe('getSessionLifecycle', () => {
it('should invoke stats:get-session-lifecycle', async () => {
const lifecycle = [
{
id: 'lc-1',
sessionId: 'session-1',
agentType: 'claude-code',
projectPath: '/project',
createdAt: Date.now() - 60000,
closedAt: Date.now(),
duration: 60000,
isRemote: false,
},
];
mockInvoke.mockResolvedValue(lifecycle);
const result = await api.getSessionLifecycle('day');
expect(mockInvoke).toHaveBeenCalledWith('stats:get-session-lifecycle', 'day');
expect(result).toEqual(lifecycle);
});
});
});

View File

@@ -0,0 +1,553 @@
/**
* Tests for system preload API
*
* Coverage:
* - createDialogApi: selectFolder, saveFile
* - createFontsApi: detect
* - createShellsApi: detect
* - createShellApi: openExternal, trashItem
* - createTunnelApi: isCloudflaredInstalled, start, stop, getStatus
* - createSyncApi: getDefaultPath, getSettings, getCurrentStoragePath, selectSyncFolder, setCustomPath
* - createDevtoolsApi: open, close, toggle
* - createPowerApi: setEnabled, isEnabled, getStatus, addReason, removeReason
* - createUpdatesApi: check, download, install, getStatus, onStatus, setAllowPrerelease
* - createAppApi: onQuitConfirmationRequest, confirmQuit, cancelQuit
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
const mockOn = vi.fn();
const mockRemoveListener = vi.fn();
const mockSend = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
on: (...args: unknown[]) => mockOn(...args),
removeListener: (...args: unknown[]) => mockRemoveListener(...args),
send: (...args: unknown[]) => mockSend(...args),
},
}));
import {
createDialogApi,
createFontsApi,
createShellsApi,
createShellApi,
createTunnelApi,
createSyncApi,
createDevtoolsApi,
createPowerApi,
createUpdatesApi,
createAppApi,
} from '../../../main/preload/system';
describe('System Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createDialogApi', () => {
let api: ReturnType<typeof createDialogApi>;
beforeEach(() => {
api = createDialogApi();
});
describe('selectFolder', () => {
it('should invoke dialog:selectFolder', async () => {
mockInvoke.mockResolvedValue('/selected/path');
const result = await api.selectFolder();
expect(mockInvoke).toHaveBeenCalledWith('dialog:selectFolder');
expect(result).toBe('/selected/path');
});
});
describe('saveFile', () => {
it('should invoke dialog:saveFile with options', async () => {
mockInvoke.mockResolvedValue('/saved/file.txt');
const options = {
defaultPath: '/default/path.txt',
filters: [{ name: 'Text', extensions: ['txt'] }],
title: 'Save File',
};
const result = await api.saveFile(options);
expect(mockInvoke).toHaveBeenCalledWith('dialog:saveFile', options);
expect(result).toBe('/saved/file.txt');
});
});
});
describe('createFontsApi', () => {
let api: ReturnType<typeof createFontsApi>;
beforeEach(() => {
api = createFontsApi();
});
describe('detect', () => {
it('should invoke fonts:detect', async () => {
mockInvoke.mockResolvedValue(['Arial', 'Helvetica', 'Monaco']);
const result = await api.detect();
expect(mockInvoke).toHaveBeenCalledWith('fonts:detect');
expect(result).toEqual(['Arial', 'Helvetica', 'Monaco']);
});
});
});
describe('createShellsApi', () => {
let api: ReturnType<typeof createShellsApi>;
beforeEach(() => {
api = createShellsApi();
});
describe('detect', () => {
it('should invoke shells:detect', async () => {
const shells = [
{ id: 'bash', name: 'Bash', available: true, path: '/bin/bash' },
{ id: 'zsh', name: 'Zsh', available: true, path: '/bin/zsh' },
];
mockInvoke.mockResolvedValue(shells);
const result = await api.detect();
expect(mockInvoke).toHaveBeenCalledWith('shells:detect');
expect(result).toEqual(shells);
});
});
});
describe('createShellApi', () => {
let api: ReturnType<typeof createShellApi>;
beforeEach(() => {
api = createShellApi();
});
describe('openExternal', () => {
it('should invoke shell:openExternal with url', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.openExternal('https://example.com');
expect(mockInvoke).toHaveBeenCalledWith('shell:openExternal', 'https://example.com');
});
});
describe('trashItem', () => {
it('should invoke shell:trashItem with path', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.trashItem('/path/to/file');
expect(mockInvoke).toHaveBeenCalledWith('shell:trashItem', '/path/to/file');
});
});
});
describe('createTunnelApi', () => {
let api: ReturnType<typeof createTunnelApi>;
beforeEach(() => {
api = createTunnelApi();
});
describe('isCloudflaredInstalled', () => {
it('should invoke tunnel:isCloudflaredInstalled', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.isCloudflaredInstalled();
expect(mockInvoke).toHaveBeenCalledWith('tunnel:isCloudflaredInstalled');
expect(result).toBe(true);
});
});
describe('start', () => {
it('should invoke tunnel:start', async () => {
mockInvoke.mockResolvedValue({ url: 'https://tunnel.example.com' });
const result = await api.start();
expect(mockInvoke).toHaveBeenCalledWith('tunnel:start');
expect(result).toEqual({ url: 'https://tunnel.example.com' });
});
});
describe('stop', () => {
it('should invoke tunnel:stop', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.stop();
expect(mockInvoke).toHaveBeenCalledWith('tunnel:stop');
});
});
describe('getStatus', () => {
it('should invoke tunnel:getStatus', async () => {
mockInvoke.mockResolvedValue({ running: true, url: 'https://tunnel.example.com' });
const result = await api.getStatus();
expect(mockInvoke).toHaveBeenCalledWith('tunnel:getStatus');
expect(result).toEqual({ running: true, url: 'https://tunnel.example.com' });
});
});
});
describe('createSyncApi', () => {
let api: ReturnType<typeof createSyncApi>;
beforeEach(() => {
api = createSyncApi();
});
describe('getDefaultPath', () => {
it('should invoke sync:getDefaultPath', async () => {
mockInvoke.mockResolvedValue('/default/sync/path');
const result = await api.getDefaultPath();
expect(mockInvoke).toHaveBeenCalledWith('sync:getDefaultPath');
expect(result).toBe('/default/sync/path');
});
});
describe('getSettings', () => {
it('should invoke sync:getSettings', async () => {
mockInvoke.mockResolvedValue({ customSyncPath: '/custom/path' });
const result = await api.getSettings();
expect(mockInvoke).toHaveBeenCalledWith('sync:getSettings');
expect(result).toEqual({ customSyncPath: '/custom/path' });
});
});
describe('getCurrentStoragePath', () => {
it('should invoke sync:getCurrentStoragePath', async () => {
mockInvoke.mockResolvedValue('/current/storage/path');
const result = await api.getCurrentStoragePath();
expect(mockInvoke).toHaveBeenCalledWith('sync:getCurrentStoragePath');
expect(result).toBe('/current/storage/path');
});
});
describe('selectSyncFolder', () => {
it('should invoke sync:selectSyncFolder', async () => {
mockInvoke.mockResolvedValue('/selected/sync/folder');
const result = await api.selectSyncFolder();
expect(mockInvoke).toHaveBeenCalledWith('sync:selectSyncFolder');
expect(result).toBe('/selected/sync/folder');
});
});
describe('setCustomPath', () => {
it('should invoke sync:setCustomPath', async () => {
mockInvoke.mockResolvedValue({ success: true, migrated: 5 });
const result = await api.setCustomPath('/new/custom/path');
expect(mockInvoke).toHaveBeenCalledWith('sync:setCustomPath', '/new/custom/path');
expect(result).toEqual({ success: true, migrated: 5 });
});
it('should handle null to reset path', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.setCustomPath(null);
expect(mockInvoke).toHaveBeenCalledWith('sync:setCustomPath', null);
expect(result.success).toBe(true);
});
});
});
describe('createDevtoolsApi', () => {
let api: ReturnType<typeof createDevtoolsApi>;
beforeEach(() => {
api = createDevtoolsApi();
});
describe('open', () => {
it('should invoke devtools:open', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.open();
expect(mockInvoke).toHaveBeenCalledWith('devtools:open');
});
});
describe('close', () => {
it('should invoke devtools:close', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.close();
expect(mockInvoke).toHaveBeenCalledWith('devtools:close');
});
});
describe('toggle', () => {
it('should invoke devtools:toggle', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.toggle();
expect(mockInvoke).toHaveBeenCalledWith('devtools:toggle');
});
});
});
describe('createPowerApi', () => {
let api: ReturnType<typeof createPowerApi>;
beforeEach(() => {
api = createPowerApi();
});
describe('setEnabled', () => {
it('should invoke power:setEnabled', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.setEnabled(true);
expect(mockInvoke).toHaveBeenCalledWith('power:setEnabled', true);
});
});
describe('isEnabled', () => {
it('should invoke power:isEnabled', async () => {
mockInvoke.mockResolvedValue(true);
const result = await api.isEnabled();
expect(mockInvoke).toHaveBeenCalledWith('power:isEnabled');
expect(result).toBe(true);
});
});
describe('getStatus', () => {
it('should invoke power:getStatus', async () => {
const status = {
enabled: true,
blocking: true,
reasons: ['Auto Run in progress'],
platform: 'darwin' as const,
};
mockInvoke.mockResolvedValue(status);
const result = await api.getStatus();
expect(mockInvoke).toHaveBeenCalledWith('power:getStatus');
expect(result).toEqual(status);
});
});
describe('addReason', () => {
it('should invoke power:addReason', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.addReason('Auto Run in progress');
expect(mockInvoke).toHaveBeenCalledWith('power:addReason', 'Auto Run in progress');
});
});
describe('removeReason', () => {
it('should invoke power:removeReason', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.removeReason('Auto Run in progress');
expect(mockInvoke).toHaveBeenCalledWith('power:removeReason', 'Auto Run in progress');
});
});
});
describe('createUpdatesApi', () => {
let api: ReturnType<typeof createUpdatesApi>;
beforeEach(() => {
api = createUpdatesApi();
});
describe('check', () => {
it('should invoke updates:check', async () => {
const updateInfo = {
currentVersion: '1.0.0',
latestVersion: '1.1.0',
updateAvailable: true,
versionsBehind: 1,
releases: [],
releasesUrl: 'https://github.com/example/releases',
};
mockInvoke.mockResolvedValue(updateInfo);
const result = await api.check();
expect(mockInvoke).toHaveBeenCalledWith('updates:check', undefined);
expect(result).toEqual(updateInfo);
});
it('should invoke updates:check with prerelease flag', async () => {
mockInvoke.mockResolvedValue({});
await api.check(true);
expect(mockInvoke).toHaveBeenCalledWith('updates:check', true);
});
});
describe('download', () => {
it('should invoke updates:download', async () => {
mockInvoke.mockResolvedValue({ success: true });
const result = await api.download();
expect(mockInvoke).toHaveBeenCalledWith('updates:download');
expect(result).toEqual({ success: true });
});
});
describe('install', () => {
it('should invoke updates:install', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.install();
expect(mockInvoke).toHaveBeenCalledWith('updates:install');
});
});
describe('getStatus', () => {
it('should invoke updates:getStatus', async () => {
const status = { status: 'idle' as const };
mockInvoke.mockResolvedValue(status);
const result = await api.getStatus();
expect(mockInvoke).toHaveBeenCalledWith('updates:getStatus');
expect(result).toEqual(status);
});
});
describe('onStatus', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onStatus(callback);
expect(mockOn).toHaveBeenCalledWith('updates:status', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, status: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, status: unknown) => void) => {
registeredHandler = handler;
}
);
api.onStatus(callback);
registeredHandler!({}, { status: 'downloading' });
expect(callback).toHaveBeenCalledWith({ status: 'downloading' });
});
it('should remove listener when cleanup is called', () => {
const callback = vi.fn();
let registeredHandler: (event: unknown, status: unknown) => void;
mockOn.mockImplementation(
(_channel: string, handler: (event: unknown, status: unknown) => void) => {
registeredHandler = handler;
}
);
const cleanup = api.onStatus(callback);
cleanup();
expect(mockRemoveListener).toHaveBeenCalledWith('updates:status', registeredHandler!);
});
});
describe('setAllowPrerelease', () => {
it('should invoke updates:setAllowPrerelease', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.setAllowPrerelease(true);
expect(mockInvoke).toHaveBeenCalledWith('updates:setAllowPrerelease', true);
});
});
});
describe('createAppApi', () => {
let api: ReturnType<typeof createAppApi>;
beforeEach(() => {
api = createAppApi();
});
describe('onQuitConfirmationRequest', () => {
it('should register event listener and return cleanup function', () => {
const callback = vi.fn();
const cleanup = api.onQuitConfirmationRequest(callback);
expect(mockOn).toHaveBeenCalledWith('app:requestQuitConfirmation', expect.any(Function));
expect(typeof cleanup).toBe('function');
});
it('should call callback when event is received', () => {
const callback = vi.fn();
let registeredHandler: () => void;
mockOn.mockImplementation((_channel: string, handler: () => void) => {
registeredHandler = handler;
});
api.onQuitConfirmationRequest(callback);
registeredHandler!();
expect(callback).toHaveBeenCalled();
});
});
describe('confirmQuit', () => {
it('should send app:quitConfirmed', () => {
api.confirmQuit();
expect(mockSend).toHaveBeenCalledWith('app:quitConfirmed');
});
});
describe('cancelQuit', () => {
it('should send app:quitCancelled', () => {
api.cancelQuit();
expect(mockSend).toHaveBeenCalledWith('app:quitCancelled');
});
});
});
});

View File

@@ -0,0 +1,272 @@
/**
* Tests for web preload API
*
* Coverage:
* - createWebApi: broadcastUserInput, broadcastAutoRunState, broadcastTabsChange, broadcastSessionState
* - createWebserverApi: getUrl, getConnectedClients
* - createLiveApi: toggle, getStatus, getDashboardUrl, getLiveSessions, broadcastActiveSession,
* disableAll, startServer, stopServer
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock electron ipcRenderer
const mockInvoke = vi.fn();
vi.mock('electron', () => ({
ipcRenderer: {
invoke: (...args: unknown[]) => mockInvoke(...args),
},
}));
import { createWebApi, createWebserverApi, createLiveApi } from '../../../main/preload/web';
describe('Web Preload API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('createWebApi', () => {
let api: ReturnType<typeof createWebApi>;
beforeEach(() => {
api = createWebApi();
});
describe('broadcastUserInput', () => {
it('should invoke web:broadcastUserInput with correct parameters', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.broadcastUserInput('session-123', 'hello world', 'ai');
expect(mockInvoke).toHaveBeenCalledWith(
'web:broadcastUserInput',
'session-123',
'hello world',
'ai'
);
});
it('should handle terminal mode', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.broadcastUserInput('session-123', 'ls -la', 'terminal');
expect(mockInvoke).toHaveBeenCalledWith(
'web:broadcastUserInput',
'session-123',
'ls -la',
'terminal'
);
});
});
describe('broadcastAutoRunState', () => {
it('should invoke web:broadcastAutoRunState with state', async () => {
mockInvoke.mockResolvedValue(undefined);
const state = {
isRunning: true,
totalTasks: 10,
completedTasks: 5,
currentTaskIndex: 5,
};
await api.broadcastAutoRunState('session-123', state);
expect(mockInvoke).toHaveBeenCalledWith('web:broadcastAutoRunState', 'session-123', state);
});
it('should handle null state', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.broadcastAutoRunState('session-123', null);
expect(mockInvoke).toHaveBeenCalledWith('web:broadcastAutoRunState', 'session-123', null);
});
});
describe('broadcastTabsChange', () => {
it('should invoke web:broadcastTabsChange with tabs', async () => {
mockInvoke.mockResolvedValue(undefined);
const tabs = [
{
id: 'tab-1',
agentSessionId: 'agent-1',
name: 'Tab 1',
starred: false,
inputValue: '',
createdAt: Date.now(),
state: 'idle' as const,
},
];
await api.broadcastTabsChange('session-123', tabs, 'tab-1');
expect(mockInvoke).toHaveBeenCalledWith(
'web:broadcastTabsChange',
'session-123',
tabs,
'tab-1'
);
});
});
describe('broadcastSessionState', () => {
it('should invoke web:broadcastSessionState with state', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.broadcastSessionState('session-123', 'busy');
expect(mockInvoke).toHaveBeenCalledWith(
'web:broadcastSessionState',
'session-123',
'busy',
undefined
);
});
it('should invoke with additional data', async () => {
mockInvoke.mockResolvedValue(undefined);
const additionalData = { name: 'My Session', toolType: 'claude-code' };
await api.broadcastSessionState('session-123', 'idle', additionalData);
expect(mockInvoke).toHaveBeenCalledWith(
'web:broadcastSessionState',
'session-123',
'idle',
additionalData
);
});
});
});
describe('createWebserverApi', () => {
let api: ReturnType<typeof createWebserverApi>;
beforeEach(() => {
api = createWebserverApi();
});
describe('getUrl', () => {
it('should invoke webserver:getUrl', async () => {
mockInvoke.mockResolvedValue('http://localhost:3000');
const result = await api.getUrl();
expect(mockInvoke).toHaveBeenCalledWith('webserver:getUrl');
expect(result).toBe('http://localhost:3000');
});
});
describe('getConnectedClients', () => {
it('should invoke webserver:getConnectedClients', async () => {
mockInvoke.mockResolvedValue(['client-1', 'client-2']);
const result = await api.getConnectedClients();
expect(mockInvoke).toHaveBeenCalledWith('webserver:getConnectedClients');
expect(result).toEqual(['client-1', 'client-2']);
});
});
});
describe('createLiveApi', () => {
let api: ReturnType<typeof createLiveApi>;
beforeEach(() => {
api = createLiveApi();
});
describe('toggle', () => {
it('should invoke live:toggle with sessionId', async () => {
mockInvoke.mockResolvedValue({ enabled: true });
await api.toggle('session-123');
expect(mockInvoke).toHaveBeenCalledWith('live:toggle', 'session-123', undefined);
});
it('should invoke live:toggle with agentSessionId', async () => {
mockInvoke.mockResolvedValue({ enabled: true });
await api.toggle('session-123', 'agent-session-456');
expect(mockInvoke).toHaveBeenCalledWith('live:toggle', 'session-123', 'agent-session-456');
});
});
describe('getStatus', () => {
it('should invoke live:getStatus', async () => {
mockInvoke.mockResolvedValue({ enabled: true, url: 'https://live.example.com' });
const result = await api.getStatus('session-123');
expect(mockInvoke).toHaveBeenCalledWith('live:getStatus', 'session-123');
expect(result).toEqual({ enabled: true, url: 'https://live.example.com' });
});
});
describe('getDashboardUrl', () => {
it('should invoke live:getDashboardUrl', async () => {
mockInvoke.mockResolvedValue('https://dashboard.example.com');
const result = await api.getDashboardUrl();
expect(mockInvoke).toHaveBeenCalledWith('live:getDashboardUrl');
expect(result).toBe('https://dashboard.example.com');
});
});
describe('getLiveSessions', () => {
it('should invoke live:getLiveSessions', async () => {
mockInvoke.mockResolvedValue([{ sessionId: '123', agentSessionId: '456' }]);
const result = await api.getLiveSessions();
expect(mockInvoke).toHaveBeenCalledWith('live:getLiveSessions');
expect(result).toEqual([{ sessionId: '123', agentSessionId: '456' }]);
});
});
describe('broadcastActiveSession', () => {
it('should invoke live:broadcastActiveSession', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.broadcastActiveSession('session-123');
expect(mockInvoke).toHaveBeenCalledWith('live:broadcastActiveSession', 'session-123');
});
});
describe('disableAll', () => {
it('should invoke live:disableAll', async () => {
mockInvoke.mockResolvedValue(undefined);
await api.disableAll();
expect(mockInvoke).toHaveBeenCalledWith('live:disableAll');
});
});
describe('startServer', () => {
it('should invoke live:startServer', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.startServer();
expect(mockInvoke).toHaveBeenCalledWith('live:startServer');
});
});
describe('stopServer', () => {
it('should invoke live:stopServer', async () => {
mockInvoke.mockResolvedValue({ success: true });
await api.stopServer();
expect(mockInvoke).toHaveBeenCalledWith('live:stopServer');
});
});
});
});

View File

@@ -1,18 +1,16 @@
/** /**
* Auto-updater module for Maestro * Auto-updater module for Maestro
* Uses electron-updater to download and install updates from GitHub releases * Uses electron-updater to download and install updates from GitHub releases
*
* Note: electron-updater accesses electron.app at module load time, so we use
* lazy initialization to avoid "Cannot read properties of undefined" errors
* when the module is imported before app.whenReady().
*/ */
import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater'; import type { UpdateInfo, ProgressInfo, AppUpdater } from 'electron-updater';
import { BrowserWindow, ipcMain } from 'electron'; import { BrowserWindow, ipcMain } from 'electron';
import { logger } from './utils/logger'; import { logger } from './utils/logger';
// Don't auto-download - we want user to initiate
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Default to stable releases only (will be updated from settings)
autoUpdater.allowPrerelease = false;
export interface UpdateStatus { export interface UpdateStatus {
status: status:
| 'idle' | 'idle'
@@ -31,12 +29,34 @@ let mainWindow: BrowserWindow | null = null;
let currentStatus: UpdateStatus = { status: 'idle' }; let currentStatus: UpdateStatus = { status: 'idle' };
let ipcHandlersRegistered = false; let ipcHandlersRegistered = false;
// Lazy-loaded autoUpdater instance
let _autoUpdater: AppUpdater | null = null;
/**
* Get the autoUpdater instance, initializing it lazily
* This is necessary because electron-updater accesses electron.app at import time
*/
function getAutoUpdater(): AppUpdater {
if (!_autoUpdater) {
// Dynamic require to defer the module load
const { autoUpdater } = require('electron-updater');
_autoUpdater = autoUpdater;
// Configure defaults
_autoUpdater!.autoDownload = false;
_autoUpdater!.autoInstallOnAppQuit = true;
_autoUpdater!.allowPrerelease = false;
}
return _autoUpdater!;
}
/** /**
* Initialize the auto-updater and set up event handlers * Initialize the auto-updater and set up event handlers
*/ */
export function initAutoUpdater(window: BrowserWindow): void { export function initAutoUpdater(window: BrowserWindow): void {
mainWindow = window; mainWindow = window;
const autoUpdater = getAutoUpdater();
// Update available // Update available
autoUpdater.on('update-available', (info: UpdateInfo) => { autoUpdater.on('update-available', (info: UpdateInfo) => {
logger.info(`Update available: ${info.version}`, 'AutoUpdater'); logger.info(`Update available: ${info.version}`, 'AutoUpdater');
@@ -93,6 +113,8 @@ function setupIpcHandlers(): void {
} }
ipcHandlersRegistered = true; ipcHandlersRegistered = true;
const autoUpdater = getAutoUpdater();
// Check for updates using electron-updater (different from manual GitHub API check) // Check for updates using electron-updater (different from manual GitHub API check)
ipcMain.handle('updates:checkAutoUpdater', async () => { ipcMain.handle('updates:checkAutoUpdater', async () => {
try { try {
@@ -159,6 +181,7 @@ function setupIpcHandlers(): void {
*/ */
export async function checkForUpdatesManual(): Promise<UpdateInfo | null> { export async function checkForUpdatesManual(): Promise<UpdateInfo | null> {
try { try {
const autoUpdater = getAutoUpdater();
const result = await autoUpdater.checkForUpdates(); const result = await autoUpdater.checkForUpdates();
return result?.updateInfo || null; return result?.updateInfo || null;
} catch { } catch {
@@ -171,6 +194,7 @@ export async function checkForUpdatesManual(): Promise<UpdateInfo | null> {
* This should be called when the user setting changes * This should be called when the user setting changes
*/ */
export function setAllowPrerelease(allow: boolean): void { export function setAllowPrerelease(allow: boolean): void {
const autoUpdater = getAutoUpdater();
autoUpdater.allowPrerelease = allow; autoUpdater.allowPrerelease = allow;
logger.info(`Auto-updater prerelease mode: ${allow ? 'enabled' : 'disabled'}`, 'AutoUpdater'); logger.info(`Auto-updater prerelease mode: ${allow ? 'enabled' : 'disabled'}`, 'AutoUpdater');
} }

View File

@@ -2,8 +2,8 @@ import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path'; import path from 'path';
import fsSync from 'fs'; import fsSync from 'fs';
import crypto from 'crypto'; import crypto from 'crypto';
import * as Sentry from '@sentry/electron/main'; // Sentry is imported dynamically below to avoid module-load-time access to electron.app
import { IPCMode } from '@sentry/electron/main'; // which causes "Cannot read properties of undefined (reading 'getAppPath')" errors
import { ProcessManager } from './process-manager'; import { ProcessManager } from './process-manager';
import { WebServer } from './web-server'; import { WebServer } from './web-server';
import { AgentDetector } from './agent-detector'; import { AgentDetector } from './agent-detector';
@@ -153,30 +153,6 @@ if (disableGpuAcceleration) {
console.log('[STARTUP] GPU hardware acceleration disabled by user preference'); console.log('[STARTUP] GPU hardware acceleration disabled by user preference');
} }
// Initialize Sentry for crash reporting
// Only enable in production - skip during development to avoid noise from hot-reload artifacts
if (crashReportingEnabled && !isDevelopment) {
Sentry.init({
dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568',
// Set release version for better debugging
release: app.getVersion(),
// Use Classic IPC mode to avoid "sentry-ipc:// URL scheme not supported" errors
// See: https://github.com/getsentry/sentry-electron/issues/661
ipcMode: IPCMode.Classic,
// Only send errors, not performance data
tracesSampleRate: 0,
// Filter out sensitive data
beforeSend(event) {
// Remove any potential sensitive data from the event
if (event.user) {
delete event.user.ip_address;
delete event.user.email;
}
return event;
},
});
}
// Generate installation ID on first run (one-time generation) // Generate installation ID on first run (one-time generation)
// This creates a unique identifier per Maestro installation for telemetry differentiation // This creates a unique identifier per Maestro installation for telemetry differentiation
const store = getSettingsStore(); const store = getSettingsStore();
@@ -187,9 +163,38 @@ if (!installationId) {
logger.info('Generated new installation ID', 'Startup', { installationId }); logger.info('Generated new installation ID', 'Startup', { installationId });
} }
// Add installation ID to Sentry for error correlation across installations // Initialize Sentry for crash reporting (dynamic import to avoid module-load-time errors)
// Only enable in production - skip during development to avoid noise from hot-reload artifacts
// The dynamic import is necessary because @sentry/electron accesses electron.app at module load time
// which fails if the module is imported before app.whenReady() in some Node/Electron version combinations
if (crashReportingEnabled && !isDevelopment) { if (crashReportingEnabled && !isDevelopment) {
Sentry.setTag('installationId', installationId); import('@sentry/electron/main')
.then(({ init, setTag, IPCMode }) => {
init({
dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568',
// Set release version for better debugging
release: app.getVersion(),
// Use Classic IPC mode to avoid "sentry-ipc:// URL scheme not supported" errors
// See: https://github.com/getsentry/sentry-electron/issues/661
ipcMode: IPCMode.Classic,
// Only send errors, not performance data
tracesSampleRate: 0,
// Filter out sensitive data
beforeSend(event) {
// Remove any potential sensitive data from the event
if (event.user) {
delete event.user.ip_address;
delete event.user.email;
}
return event;
},
});
// Add installation ID to Sentry for error correlation across installations
setTag('installationId', installationId);
})
.catch((err) => {
logger.warn('Failed to initialize Sentry', 'Startup', { error: String(err) });
});
} }
// Create local references to stores for use throughout this module // Create local references to stores for use throughout this module

File diff suppressed because it is too large Load Diff

189
src/main/preload/agents.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* Preload API for agent management
*
* Provides the window.maestro.agents namespace for:
* - Detecting available agents (Claude Code, Codex, OpenCode, etc.)
* - Managing agent configurations and custom paths
* - Getting agent capabilities
* - Discovering slash commands and models
*/
import { ipcRenderer } from 'electron';
/**
* Capability flags that determine what features are available for each agent.
* This is a simplified version for the renderer - full definition in agent-capabilities.ts
*/
export interface AgentCapabilities {
supportsResume: boolean;
supportsReadOnlyMode: boolean;
supportsJsonOutput: boolean;
supportsSessionId: boolean;
supportsImageInput: boolean;
supportsImageInputOnResume: boolean;
supportsSlashCommands: boolean;
supportsSessionStorage: boolean;
supportsCostTracking: boolean;
supportsUsageStats: boolean;
supportsBatchMode: boolean;
requiresPromptToStart: boolean;
supportsStreaming: boolean;
supportsResultMessages: boolean;
supportsModelSelection: boolean;
supportsStreamJsonInput: boolean;
}
/**
* Agent configuration
*/
export interface AgentConfig {
id: string;
name: string;
command: string;
args?: string[];
available: boolean;
path?: string;
capabilities?: AgentCapabilities;
}
/**
* Agent refresh result
*/
export interface AgentRefreshResult {
agents: AgentConfig[];
debugInfo: unknown;
}
/**
* Creates the agents API object for preload exposure
*/
export function createAgentsApi() {
return {
/**
* Detect available agents
*/
detect: (sshRemoteId?: string): Promise<AgentConfig[]> =>
ipcRenderer.invoke('agents:detect', sshRemoteId),
/**
* Refresh agent detection (optionally for a specific agent)
*/
refresh: (agentId?: string, sshRemoteId?: string): Promise<AgentRefreshResult> =>
ipcRenderer.invoke('agents:refresh', agentId, sshRemoteId),
/**
* Get a specific agent's configuration
*/
get: (agentId: string): Promise<AgentConfig | null> =>
ipcRenderer.invoke('agents:get', agentId),
/**
* Get an agent's capabilities
*/
getCapabilities: (agentId: string): Promise<AgentCapabilities> =>
ipcRenderer.invoke('agents:getCapabilities', agentId),
/**
* Get an agent's full configuration
*/
getConfig: (agentId: string): Promise<Record<string, unknown>> =>
ipcRenderer.invoke('agents:getConfig', agentId),
/**
* Set an agent's configuration
*/
setConfig: (agentId: string, config: Record<string, unknown>): Promise<boolean> =>
ipcRenderer.invoke('agents:setConfig', agentId, config),
/**
* Get a specific configuration value for an agent
*/
getConfigValue: (agentId: string, key: string): Promise<unknown> =>
ipcRenderer.invoke('agents:getConfigValue', agentId, key),
/**
* Set a specific configuration value for an agent
*/
setConfigValue: (agentId: string, key: string, value: unknown): Promise<boolean> =>
ipcRenderer.invoke('agents:setConfigValue', agentId, key, value),
/**
* Set a custom path for an agent
*/
setCustomPath: (agentId: string, customPath: string | null): Promise<boolean> =>
ipcRenderer.invoke('agents:setCustomPath', agentId, customPath),
/**
* Get the custom path for an agent
*/
getCustomPath: (agentId: string): Promise<string | null> =>
ipcRenderer.invoke('agents:getCustomPath', agentId),
/**
* Get all custom paths for all agents
*/
getAllCustomPaths: (): Promise<Record<string, string>> =>
ipcRenderer.invoke('agents:getAllCustomPaths'),
/**
* Set custom CLI arguments that are appended to all agent invocations
*/
setCustomArgs: (agentId: string, customArgs: string | null): Promise<boolean> =>
ipcRenderer.invoke('agents:setCustomArgs', agentId, customArgs),
/**
* Get custom CLI arguments for an agent
*/
getCustomArgs: (agentId: string): Promise<string | null> =>
ipcRenderer.invoke('agents:getCustomArgs', agentId),
/**
* Get all custom arguments for all agents
*/
getAllCustomArgs: (): Promise<Record<string, string>> =>
ipcRenderer.invoke('agents:getAllCustomArgs'),
/**
* Set custom environment variables that are passed to all agent invocations
*/
setCustomEnvVars: (
agentId: string,
customEnvVars: Record<string, string> | null
): Promise<boolean> => ipcRenderer.invoke('agents:setCustomEnvVars', agentId, customEnvVars),
/**
* Get custom environment variables for an agent
*/
getCustomEnvVars: (agentId: string): Promise<Record<string, string> | null> =>
ipcRenderer.invoke('agents:getCustomEnvVars', agentId),
/**
* Get all custom environment variables for all agents
*/
getAllCustomEnvVars: (): Promise<Record<string, Record<string, string>>> =>
ipcRenderer.invoke('agents:getAllCustomEnvVars'),
/**
* Discover available models for agents that support model selection
* (e.g., OpenCode with Ollama)
*/
getModels: (agentId: string, forceRefresh?: boolean): Promise<string[]> =>
ipcRenderer.invoke('agents:getModels', agentId, forceRefresh),
/**
* Discover available slash commands for an agent by spawning it briefly
* Returns array of command names (e.g., ['compact', 'help', 'my-custom-command'])
*/
discoverSlashCommands: (
agentId: string,
cwd: string,
customPath?: string
): Promise<string[] | null> =>
ipcRenderer.invoke('agents:discoverSlashCommands', agentId, cwd, customPath),
};
}
/**
* TypeScript type for the agents API
*/
export type AgentsApi = ReturnType<typeof createAgentsApi>;

View File

@@ -0,0 +1,96 @@
/**
* Preload API for attachments
*
* Provides the window.maestro.attachments namespace for:
* - Per-session image storage for scratchpad
* - Saving, loading, deleting attachments
* - Listing attachments for a session
*/
import { ipcRenderer } from 'electron';
/**
* Response from attachment operations
*/
export interface AttachmentResponse {
success: boolean;
error?: string;
}
/**
* Response from loading an attachment
*/
export interface AttachmentLoadResponse {
success: boolean;
data?: string;
error?: string;
}
/**
* Response from listing attachments
*/
export interface AttachmentListResponse {
success: boolean;
files?: string[];
error?: string;
}
/**
* Response from getting attachment path
*/
export interface AttachmentPathResponse {
success: boolean;
path?: string;
error?: string;
}
/**
* Creates the attachments API object for preload exposure
*/
export function createAttachmentsApi() {
return {
/**
* Save an attachment for a session
* @param sessionId - Session ID
* @param base64Data - Base64 encoded file data
* @param filename - Filename to save as
*/
save: (sessionId: string, base64Data: string, filename: string): Promise<AttachmentResponse> =>
ipcRenderer.invoke('attachments:save', sessionId, base64Data, filename),
/**
* Load an attachment for a session
* @param sessionId - Session ID
* @param filename - Filename to load
*/
load: (sessionId: string, filename: string): Promise<AttachmentLoadResponse> =>
ipcRenderer.invoke('attachments:load', sessionId, filename),
/**
* Delete an attachment for a session
* @param sessionId - Session ID
* @param filename - Filename to delete
*/
delete: (sessionId: string, filename: string): Promise<AttachmentResponse> =>
ipcRenderer.invoke('attachments:delete', sessionId, filename),
/**
* List all attachments for a session
* @param sessionId - Session ID
*/
list: (sessionId: string): Promise<AttachmentListResponse> =>
ipcRenderer.invoke('attachments:list', sessionId),
/**
* Get the filesystem path for session attachments
* @param sessionId - Session ID
*/
getPath: (sessionId: string): Promise<AttachmentPathResponse> =>
ipcRenderer.invoke('attachments:getPath', sessionId),
};
}
/**
* TypeScript type for the attachments API
*/
export type AttachmentsApi = ReturnType<typeof createAttachmentsApi>;

174
src/main/preload/autorun.ts Normal file
View File

@@ -0,0 +1,174 @@
/**
* Preload API for Auto Run operations
*
* Provides the window.maestro.autorun, playbooks, and marketplace namespaces for:
* - Auto Run document management
* - Playbook CRUD operations
* - Marketplace playbook browsing and importing
*/
import { ipcRenderer } from 'electron';
/**
* Playbook document configuration
*/
export interface PlaybookDocument {
filename: string;
resetOnCompletion: boolean;
}
/**
* Worktree settings for playbook
*/
export interface WorktreeSettings {
branchNameTemplate: string;
createPROnCompletion: boolean;
prTargetBranch?: string;
}
/**
* Playbook definition
*/
export interface Playbook {
name: string;
documents: PlaybookDocument[];
loopEnabled: boolean;
maxLoops?: number | null;
prompt: string;
worktreeSettings?: WorktreeSettings;
}
/**
* Creates the Auto Run API object for preload exposure
*/
export function createAutorunApi() {
return {
listDocs: (folderPath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:listDocs', folderPath, sshRemoteId),
hasDocuments: (folderPath: string): Promise<{ hasDocuments: boolean }> =>
ipcRenderer.invoke('autorun:hasDocuments', folderPath),
readDoc: (folderPath: string, filename: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:readDoc', folderPath, filename, sshRemoteId),
writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content, sshRemoteId),
saveImage: (folderPath: string, docName: string, base64Data: string, extension: string) =>
ipcRenderer.invoke('autorun:saveImage', folderPath, docName, base64Data, extension),
deleteImage: (folderPath: string, relativePath: string) =>
ipcRenderer.invoke('autorun:deleteImage', folderPath, relativePath),
listImages: (folderPath: string, docName: string) =>
ipcRenderer.invoke('autorun:listImages', folderPath, docName),
deleteFolder: (projectPath: string) => ipcRenderer.invoke('autorun:deleteFolder', projectPath),
watchFolder: (
folderPath: string,
sshRemoteId?: string
): Promise<{ isRemote?: boolean; message?: string }> =>
ipcRenderer.invoke('autorun:watchFolder', folderPath, sshRemoteId),
unwatchFolder: (folderPath: string) => ipcRenderer.invoke('autorun:unwatchFolder', folderPath),
onFileChanged: (
handler: (data: { folderPath: string; filename: string; eventType: string }) => void
) => {
const wrappedHandler = (
_event: Electron.IpcRendererEvent,
data: { folderPath: string; filename: string; eventType: string }
) => handler(data);
ipcRenderer.on('autorun:fileChanged', wrappedHandler);
return () => ipcRenderer.removeListener('autorun:fileChanged', wrappedHandler);
},
createBackup: (folderPath: string, filename: string) =>
ipcRenderer.invoke('autorun:createBackup', folderPath, filename),
restoreBackup: (folderPath: string, filename: string) =>
ipcRenderer.invoke('autorun:restoreBackup', folderPath, filename),
deleteBackups: (folderPath: string) => ipcRenderer.invoke('autorun:deleteBackups', folderPath),
createWorkingCopy: (
folderPath: string,
filename: string,
loopNumber: number
): Promise<{ workingCopyPath: string; originalPath: string }> =>
ipcRenderer.invoke('autorun:createWorkingCopy', folderPath, filename, loopNumber),
};
}
/**
* Creates the Playbooks API object for preload exposure
*/
export function createPlaybooksApi() {
return {
list: (sessionId: string) => ipcRenderer.invoke('playbooks:list', sessionId),
create: (sessionId: string, playbook: Playbook) =>
ipcRenderer.invoke('playbooks:create', sessionId, playbook),
update: (
sessionId: string,
playbookId: string,
updates: Partial<Playbook & { updatedAt: number }>
) => ipcRenderer.invoke('playbooks:update', sessionId, playbookId, updates),
delete: (sessionId: string, playbookId: string) =>
ipcRenderer.invoke('playbooks:delete', sessionId, playbookId),
deleteAll: (sessionId: string) => ipcRenderer.invoke('playbooks:deleteAll', sessionId),
export: (sessionId: string, playbookId: string, autoRunFolderPath: string) =>
ipcRenderer.invoke('playbooks:export', sessionId, playbookId, autoRunFolderPath),
import: (sessionId: string, autoRunFolderPath: string) =>
ipcRenderer.invoke('playbooks:import', sessionId, autoRunFolderPath),
};
}
/**
* Creates the Marketplace API object for preload exposure
*/
export function createMarketplaceApi() {
return {
getManifest: () => ipcRenderer.invoke('marketplace:getManifest'),
refreshManifest: () => ipcRenderer.invoke('marketplace:refreshManifest'),
getDocument: (playbookPath: string, filename: string) =>
ipcRenderer.invoke('marketplace:getDocument', playbookPath, filename),
getReadme: (playbookPath: string) => ipcRenderer.invoke('marketplace:getReadme', playbookPath),
importPlaybook: (
playbookId: string,
targetFolderName: string,
autoRunFolderPath: string,
sessionId: string,
sshRemoteId?: string
) =>
ipcRenderer.invoke(
'marketplace:importPlaybook',
playbookId,
targetFolderName,
autoRunFolderPath,
sessionId,
sshRemoteId
),
onManifestChanged: (handler: () => void) => {
const wrappedHandler = () => handler();
ipcRenderer.on('marketplace:manifestChanged', wrappedHandler);
return () => ipcRenderer.removeListener('marketplace:manifestChanged', wrappedHandler);
},
};
}
export type AutorunApi = ReturnType<typeof createAutorunApi>;
export type PlaybooksApi = ReturnType<typeof createPlaybooksApi>;
export type MarketplaceApi = ReturnType<typeof createMarketplaceApi>;

View File

@@ -0,0 +1,132 @@
/**
* Preload API for slash commands
*
* Provides the window.maestro.speckit and window.maestro.openspec namespaces for:
* - Spec-Kit slash commands
* - OpenSpec slash commands
*/
import { ipcRenderer } from 'electron';
/**
* Command metadata
*/
export interface CommandMetadata {
lastRefreshed: string;
commitSha: string;
sourceVersion: string;
sourceUrl: string;
}
/**
* Command definition
*/
export interface CommandDefinition {
id: string;
command: string;
description: string;
prompt: string;
isCustom: boolean;
isModified: boolean;
}
/**
* Creates the Spec-Kit API object for preload exposure
*/
export function createSpeckitApi() {
return {
getMetadata: (): Promise<{
success: boolean;
metadata?: CommandMetadata;
error?: string;
}> => ipcRenderer.invoke('speckit:getMetadata'),
getPrompts: (): Promise<{
success: boolean;
commands?: CommandDefinition[];
error?: string;
}> => ipcRenderer.invoke('speckit:getPrompts'),
getCommand: (
slashCommand: string
): Promise<{
success: boolean;
command?: CommandDefinition | null;
error?: string;
}> => ipcRenderer.invoke('speckit:getCommand', slashCommand),
savePrompt: (
id: string,
content: string
): Promise<{
success: boolean;
error?: string;
}> => ipcRenderer.invoke('speckit:savePrompt', id, content),
resetPrompt: (
id: string
): Promise<{
success: boolean;
prompt?: string;
error?: string;
}> => ipcRenderer.invoke('speckit:resetPrompt', id),
refresh: (): Promise<{
success: boolean;
metadata?: CommandMetadata;
error?: string;
}> => ipcRenderer.invoke('speckit:refresh'),
};
}
/**
* Creates the OpenSpec API object for preload exposure
*/
export function createOpenspecApi() {
return {
getMetadata: (): Promise<{
success: boolean;
metadata?: CommandMetadata;
error?: string;
}> => ipcRenderer.invoke('openspec:getMetadata'),
getPrompts: (): Promise<{
success: boolean;
commands?: CommandDefinition[];
error?: string;
}> => ipcRenderer.invoke('openspec:getPrompts'),
getCommand: (
slashCommand: string
): Promise<{
success: boolean;
command?: CommandDefinition | null;
error?: string;
}> => ipcRenderer.invoke('openspec:getCommand', slashCommand),
savePrompt: (
id: string,
content: string
): Promise<{
success: boolean;
error?: string;
}> => ipcRenderer.invoke('openspec:savePrompt', id, content),
resetPrompt: (
id: string
): Promise<{
success: boolean;
prompt?: string;
error?: string;
}> => ipcRenderer.invoke('openspec:resetPrompt', id),
refresh: (): Promise<{
success: boolean;
metadata?: CommandMetadata;
error?: string;
}> => ipcRenderer.invoke('openspec:refresh'),
};
}
export type SpeckitApi = ReturnType<typeof createSpeckitApi>;
export type OpenspecApi = ReturnType<typeof createOpenspecApi>;

View File

@@ -0,0 +1,66 @@
/**
* Preload API for context operations
*
* Provides the window.maestro.context namespace for:
* - Session context transfer and grooming
* - Context retrieval from stored sessions
*/
import { ipcRenderer } from 'electron';
/**
* Message structure from stored sessions
*/
export interface StoredMessage {
type: string;
role?: string;
content: string;
timestamp: string;
uuid: string;
toolUse?: unknown;
}
/**
* Response from getStoredSession
*/
export interface StoredSessionResponse {
messages: StoredMessage[];
total: number;
hasMore: boolean;
}
/**
* Creates the context API object for preload exposure
*/
export function createContextApi() {
return {
// Get context from a stored agent session
getStoredSession: (
agentId: string,
projectRoot: string,
sessionId: string
): Promise<StoredSessionResponse | null> =>
ipcRenderer.invoke('context:getStoredSession', agentId, projectRoot, sessionId),
// Single-call grooming (recommended) - spawns batch process and returns response
groomContext: (projectRoot: string, agentType: string, prompt: string): Promise<string> =>
ipcRenderer.invoke('context:groomContext', projectRoot, agentType, prompt),
// Cancel all active grooming sessions
cancelGrooming: (): Promise<void> => ipcRenderer.invoke('context:cancelGrooming'),
// DEPRECATED: Create a temporary session for context grooming
createGroomingSession: (projectRoot: string, agentType: string): Promise<string> =>
ipcRenderer.invoke('context:createGroomingSession', projectRoot, agentType),
// DEPRECATED: Send grooming prompt to a session and get response
sendGroomingPrompt: (sessionId: string, prompt: string): Promise<string> =>
ipcRenderer.invoke('context:sendGroomingPrompt', sessionId, prompt),
// Clean up a temporary grooming session
cleanupGroomingSession: (sessionId: string): Promise<void> =>
ipcRenderer.invoke('context:cleanupGroomingSession', sessionId),
};
}
export type ContextApi = ReturnType<typeof createContextApi>;

66
src/main/preload/debug.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Preload API for debug and document graph operations
*
* Provides the window.maestro.debug and window.maestro.documentGraph namespaces for:
* - Debug package generation
* - Document graph file watching
*/
import { ipcRenderer } from 'electron';
/**
* Debug package options
*/
export interface DebugPackageOptions {
includeLogs?: boolean;
includeErrors?: boolean;
includeSessions?: boolean;
includeGroupChats?: boolean;
includeBatchState?: boolean;
}
/**
* Document graph file change event
*/
export interface DocumentGraphChange {
filePath: string;
eventType: 'add' | 'change' | 'unlink';
}
/**
* Creates the Debug API object for preload exposure
*/
export function createDebugApi() {
return {
createPackage: (options?: DebugPackageOptions) =>
ipcRenderer.invoke('debug:createPackage', options),
previewPackage: () => ipcRenderer.invoke('debug:previewPackage'),
};
}
/**
* Creates the Document Graph API object for preload exposure
*/
export function createDocumentGraphApi() {
return {
watchFolder: (rootPath: string) => ipcRenderer.invoke('documentGraph:watchFolder', rootPath),
unwatchFolder: (rootPath: string) =>
ipcRenderer.invoke('documentGraph:unwatchFolder', rootPath),
onFilesChanged: (
handler: (data: { rootPath: string; changes: DocumentGraphChange[] }) => void
) => {
const wrappedHandler = (
_event: Electron.IpcRendererEvent,
data: { rootPath: string; changes: DocumentGraphChange[] }
) => handler(data);
ipcRenderer.on('documentGraph:filesChanged', wrappedHandler);
return () => ipcRenderer.removeListener('documentGraph:filesChanged', wrappedHandler);
},
};
}
export type DebugApi = ReturnType<typeof createDebugApi>;
export type DocumentGraphApi = ReturnType<typeof createDocumentGraphApi>;

108
src/main/preload/files.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* Preload API for file operations
*
* Provides the window.maestro.tempfile, history, and cli namespaces for:
* - Temporary file operations
* - History persistence
* - CLI activity monitoring
*/
import { ipcRenderer } from 'electron';
/**
* History entry
*/
export interface HistoryEntry {
id: string;
type: 'AUTO' | 'USER';
timestamp: number;
summary: string;
fullResponse?: string;
agentSessionId?: string;
projectPath: string;
sessionId?: string;
sessionName?: string;
contextUsage?: number;
usageStats?: {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
totalCostUsd: number;
contextWindow: number;
};
success?: boolean;
elapsedTimeMs?: number;
validated?: boolean;
}
/**
* Creates the tempfile API object for preload exposure
*/
export function createTempfileApi() {
return {
write: (content: string, filename?: string) =>
ipcRenderer.invoke('tempfile:write', content, filename),
read: (filePath: string) => ipcRenderer.invoke('tempfile:read', filePath),
delete: (filePath: string) => ipcRenderer.invoke('tempfile:delete', filePath),
};
}
/**
* Creates the history API object for preload exposure
*/
export function createHistoryApi() {
return {
getAll: (projectPath?: string, sessionId?: string) =>
ipcRenderer.invoke('history:getAll', projectPath, sessionId),
getAllPaginated: (options?: {
projectPath?: string;
sessionId?: string;
pagination?: { limit?: number; offset?: number };
}) => ipcRenderer.invoke('history:getAllPaginated', options),
add: (entry: HistoryEntry) => ipcRenderer.invoke('history:add', entry),
clear: (projectPath?: string) => ipcRenderer.invoke('history:clear', projectPath),
delete: (entryId: string, sessionId?: string) =>
ipcRenderer.invoke('history:delete', entryId, sessionId),
update: (entryId: string, updates: { validated?: boolean }, sessionId?: string) =>
ipcRenderer.invoke('history:update', entryId, updates, sessionId),
updateSessionName: (agentSessionId: string, sessionName: string) =>
ipcRenderer.invoke('history:updateSessionName', agentSessionId, sessionName),
getFilePath: (sessionId: string) => ipcRenderer.invoke('history:getFilePath', sessionId),
listSessions: () => ipcRenderer.invoke('history:listSessions'),
onExternalChange: (handler: () => void) => {
const wrappedHandler = () => handler();
ipcRenderer.on('history:externalChange', wrappedHandler);
return () => ipcRenderer.removeListener('history:externalChange', wrappedHandler);
},
reload: () => ipcRenderer.invoke('history:reload'),
};
}
/**
* Creates the CLI activity API object for preload exposure
*/
export function createCliApi() {
return {
getActivity: () => ipcRenderer.invoke('cli:getActivity'),
onActivityChange: (handler: () => void) => {
const wrappedHandler = () => handler();
ipcRenderer.on('cli:activityChange', wrappedHandler);
return () => ipcRenderer.removeListener('cli:activityChange', wrappedHandler);
},
};
}
export type TempfileApi = ReturnType<typeof createTempfileApi>;
export type HistoryApi = ReturnType<typeof createHistoryApi>;
export type CliApi = ReturnType<typeof createCliApi>;

125
src/main/preload/fs.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* Preload API for filesystem operations
*
* Provides the window.maestro.fs namespace for:
* - Reading directories and files
* - File stats and sizes
* - Writing, renaming, and deleting files
* - SSH remote support for all operations
*/
import { ipcRenderer } from 'electron';
/**
* Directory entry information
*/
export interface DirectoryEntry {
name: string;
isDirectory: boolean;
path: string;
}
/**
* File stat information
*/
export interface FileStat {
size: number;
createdAt: string;
modifiedAt: string;
isDirectory: boolean;
isFile: boolean;
}
/**
* Directory size information
*/
export interface DirectorySizeInfo {
totalSize: number;
fileCount: number;
folderCount: number;
}
/**
* Item count information
*/
export interface ItemCountInfo {
fileCount: number;
folderCount: number;
}
/**
* Creates the filesystem API object for preload exposure
*/
export function createFsApi() {
return {
/**
* Get the user's home directory
*/
homeDir: (): Promise<string> => ipcRenderer.invoke('fs:homeDir'),
/**
* Read directory contents
*/
readDir: (dirPath: string, sshRemoteId?: string): Promise<DirectoryEntry[]> =>
ipcRenderer.invoke('fs:readDir', dirPath, sshRemoteId),
/**
* Read file contents
*/
readFile: (filePath: string, sshRemoteId?: string): Promise<string> =>
ipcRenderer.invoke('fs:readFile', filePath, sshRemoteId),
/**
* Write file contents
*/
writeFile: (filePath: string, content: string): Promise<{ success: boolean }> =>
ipcRenderer.invoke('fs:writeFile', filePath, content),
/**
* Get file/directory stats
*/
stat: (filePath: string, sshRemoteId?: string): Promise<FileStat> =>
ipcRenderer.invoke('fs:stat', filePath, sshRemoteId),
/**
* Get directory size information
*/
directorySize: (dirPath: string, sshRemoteId?: string): Promise<DirectorySizeInfo> =>
ipcRenderer.invoke('fs:directorySize', dirPath, sshRemoteId),
/**
* Fetch an image from URL and return as base64
*/
fetchImageAsBase64: (url: string): Promise<string | null> =>
ipcRenderer.invoke('fs:fetchImageAsBase64', url),
/**
* Rename a file or directory
*/
rename: (
oldPath: string,
newPath: string,
sshRemoteId?: string
): Promise<{ success: boolean }> =>
ipcRenderer.invoke('fs:rename', oldPath, newPath, sshRemoteId),
/**
* Delete a file or directory
*/
delete: (
targetPath: string,
options?: { recursive?: boolean; sshRemoteId?: string }
): Promise<{ success: boolean }> => ipcRenderer.invoke('fs:delete', targetPath, options),
/**
* Count files and folders in a directory
*/
countItems: (dirPath: string, sshRemoteId?: string): Promise<ItemCountInfo> =>
ipcRenderer.invoke('fs:countItems', dirPath, sshRemoteId),
};
}
/**
* TypeScript type for the filesystem API
*/
export type FsApi = ReturnType<typeof createFsApi>;

359
src/main/preload/git.ts Normal file
View File

@@ -0,0 +1,359 @@
/**
* Preload API for git operations
*
* Provides the window.maestro.git namespace for:
* - Git status, diff, branch operations
* - Git log and commit viewing
* - Git worktree operations for Auto Run parallelization
* - GitHub CLI integration (PR creation, gists)
* - SSH remote support for all operations
*/
import { ipcRenderer } from 'electron';
/**
* Git worktree information
*/
export interface WorktreeInfo {
success: boolean;
exists?: boolean;
isWorktree?: boolean;
currentBranch?: string;
repoRoot?: string;
error?: string;
}
/**
* Git worktree list entry
*/
export interface WorktreeEntry {
path: string;
head: string;
branch: string | null;
isBare: boolean;
}
/**
* Git subdirectory scan result
*/
export interface GitSubdirEntry {
path: string;
name: string;
isWorktree: boolean;
branch: string | null;
repoRoot: string | null;
}
/**
* Git log entry
*/
export interface GitLogEntry {
hash: string;
shortHash: string;
author: string;
date: string;
refs: string[];
subject: string;
}
/**
* Discovered worktree event data
*/
export interface WorktreeDiscoveredData {
sessionId: string;
worktree: {
path: string;
name: string;
branch: string | null;
};
}
/**
* Creates the git API object for preload exposure
*/
export function createGitApi() {
return {
/**
* Get git status for a repository
*/
status: (cwd: string, sshRemoteId?: string, remoteCwd?: string): Promise<string> =>
ipcRenderer.invoke('git:status', cwd, sshRemoteId, remoteCwd),
/**
* Get git diff for a repository or specific file
*/
diff: (cwd: string, file?: string, sshRemoteId?: string, remoteCwd?: string): Promise<string> =>
ipcRenderer.invoke('git:diff', cwd, file, sshRemoteId, remoteCwd),
/**
* Check if a directory is a git repository
*/
isRepo: (cwd: string, sshRemoteId?: string, remoteCwd?: string): Promise<boolean> =>
ipcRenderer.invoke('git:isRepo', cwd, sshRemoteId, remoteCwd),
/**
* Get git diff numstat
*/
numstat: (
cwd: string,
sshRemoteId?: string,
remoteCwd?: string
): Promise<{ stdout: string; stderr: string }> =>
ipcRenderer.invoke('git:numstat', cwd, sshRemoteId, remoteCwd),
/**
* Get current branch name
*/
branch: (
cwd: string,
sshRemoteId?: string,
remoteCwd?: string
): Promise<{ stdout: string; stderr: string }> =>
ipcRenderer.invoke('git:branch', cwd, sshRemoteId, remoteCwd),
/**
* Get list of all branches
*/
branches: (
cwd: string,
sshRemoteId?: string,
remoteCwd?: string
): Promise<{ stdout: string; stderr: string }> =>
ipcRenderer.invoke('git:branches', cwd, sshRemoteId, remoteCwd),
/**
* Get list of tags
*/
tags: (
cwd: string,
sshRemoteId?: string,
remoteCwd?: string
): Promise<{ stdout: string; stderr: string }> =>
ipcRenderer.invoke('git:tags', cwd, sshRemoteId, remoteCwd),
/**
* Get remote URL
*/
remote: (
cwd: string,
sshRemoteId?: string,
remoteCwd?: string
): Promise<{ stdout: string; stderr: string }> =>
ipcRenderer.invoke('git:remote', cwd, sshRemoteId, remoteCwd),
/**
* Get comprehensive git info (branch, remote, ahead/behind, changes)
*/
info: (
cwd: string,
sshRemoteId?: string,
remoteCwd?: string
): Promise<{
branch: string;
remote: string;
behind: number;
ahead: number;
uncommittedChanges: number;
}> => ipcRenderer.invoke('git:info', cwd, sshRemoteId, remoteCwd),
/**
* Get git log with optional limit and search
*/
log: (
cwd: string,
options?: { limit?: number; search?: string }
): Promise<{
entries: GitLogEntry[];
error: string | null;
}> => ipcRenderer.invoke('git:log', cwd, options),
/**
* Get commit count
*/
commitCount: (cwd: string): Promise<{ count: number; error: string | null }> =>
ipcRenderer.invoke('git:commitCount', cwd),
/**
* Show a specific commit
*/
show: (cwd: string, hash: string): Promise<{ stdout: string; stderr: string }> =>
ipcRenderer.invoke('git:show', cwd, hash),
/**
* Show file content at a specific ref
*/
showFile: (
cwd: string,
ref: string,
filePath: string
): Promise<{ content?: string; error?: string }> =>
ipcRenderer.invoke('git:showFile', cwd, ref, filePath),
// Git worktree operations for Auto Run parallelization
// All worktree operations support SSH remote execution via optional sshRemoteId parameter
/**
* Get worktree information
*/
worktreeInfo: (worktreePath: string, sshRemoteId?: string): Promise<WorktreeInfo> =>
ipcRenderer.invoke('git:worktreeInfo', worktreePath, sshRemoteId),
/**
* Get the root of a git repository
*/
getRepoRoot: (
cwd: string,
sshRemoteId?: string
): Promise<{ success: boolean; root?: string; error?: string }> =>
ipcRenderer.invoke('git:getRepoRoot', cwd, sshRemoteId),
/**
* Setup a worktree (create if needed)
*/
worktreeSetup: (
mainRepoCwd: string,
worktreePath: string,
branchName: string,
sshRemoteId?: string
): Promise<{
success: boolean;
created?: boolean;
currentBranch?: string;
requestedBranch?: string;
branchMismatch?: boolean;
error?: string;
}> =>
ipcRenderer.invoke('git:worktreeSetup', mainRepoCwd, worktreePath, branchName, sshRemoteId),
/**
* Checkout a branch in a worktree
*/
worktreeCheckout: (
worktreePath: string,
branchName: string,
createIfMissing: boolean,
sshRemoteId?: string
): Promise<{
success: boolean;
hasUncommittedChanges: boolean;
error?: string;
}> =>
ipcRenderer.invoke(
'git:worktreeCheckout',
worktreePath,
branchName,
createIfMissing,
sshRemoteId
),
/**
* Create a GitHub PR
*/
createPR: (
worktreePath: string,
baseBranch: string,
title: string,
body: string,
ghPath?: string
): Promise<{
success: boolean;
prUrl?: string;
error?: string;
}> => ipcRenderer.invoke('git:createPR', worktreePath, baseBranch, title, body, ghPath),
/**
* Get the default branch of a repository
*/
getDefaultBranch: (
cwd: string
): Promise<{ success: boolean; branch?: string; error?: string }> =>
ipcRenderer.invoke('git:getDefaultBranch', cwd),
/**
* Check if GitHub CLI is installed and authenticated
*/
checkGhCli: (ghPath?: string): Promise<{ installed: boolean; authenticated: boolean }> =>
ipcRenderer.invoke('git:checkGhCli', ghPath),
/**
* Create a GitHub Gist from file content
*/
createGist: (
filename: string,
content: string,
description: string,
isPublic: boolean,
ghPath?: string
): Promise<{
success: boolean;
gistUrl?: string;
error?: string;
}> => ipcRenderer.invoke('git:createGist', filename, content, description, isPublic, ghPath),
/**
* List all worktrees for a git repository
* Supports SSH remote execution via optional sshRemoteId parameter
*/
listWorktrees: (cwd: string, sshRemoteId?: string): Promise<{ worktrees: WorktreeEntry[] }> =>
ipcRenderer.invoke('git:listWorktrees', cwd, sshRemoteId),
/**
* Scan a directory for subdirectories that are git repositories or worktrees
* Supports SSH remote execution via optional sshRemoteId parameter
*/
scanWorktreeDirectory: (
parentPath: string,
sshRemoteId?: string
): Promise<{ gitSubdirs: GitSubdirEntry[] }> =>
ipcRenderer.invoke('git:scanWorktreeDirectory', parentPath, sshRemoteId),
/**
* Watch a worktree directory for new worktrees
* Note: File watching is not available for SSH remote sessions.
* For remote sessions, returns isRemote: true indicating polling should be used instead.
*/
watchWorktreeDirectory: (
sessionId: string,
worktreePath: string,
sshRemoteId?: string
): Promise<{
success: boolean;
error?: string;
isRemote?: boolean;
message?: string;
}> => ipcRenderer.invoke('git:watchWorktreeDirectory', sessionId, worktreePath, sshRemoteId),
/**
* Stop watching a worktree directory
*/
unwatchWorktreeDirectory: (sessionId: string): Promise<{ success: boolean }> =>
ipcRenderer.invoke('git:unwatchWorktreeDirectory', sessionId),
/**
* Remove a worktree directory from disk
*/
removeWorktree: (
worktreePath: string,
force?: boolean
): Promise<{
success: boolean;
error?: string;
hasUncommittedChanges?: boolean;
}> => ipcRenderer.invoke('git:removeWorktree', worktreePath, force),
/**
* Subscribe to discovered worktrees
*/
onWorktreeDiscovered: (callback: (data: WorktreeDiscoveredData) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: WorktreeDiscoveredData) =>
callback(data);
ipcRenderer.on('worktree:discovered', handler);
return () => ipcRenderer.removeListener('worktree:discovered', handler);
},
};
}
/**
* TypeScript type for the git API
*/
export type GitApi = ReturnType<typeof createGitApi>;

View File

@@ -0,0 +1,213 @@
/**
* Preload API for group chat operations
*
* Provides the window.maestro.groupChat namespace for:
* - Group chat creation and management
* - Moderator and participant control
* - Chat history and messages
*/
import { ipcRenderer } from 'electron';
/**
* Moderator configuration
*/
export interface ModeratorConfig {
customPath?: string;
customArgs?: string;
customEnvVars?: Record<string, string>;
}
/**
* Participant definition
*/
export interface Participant {
name: string;
agentId: string;
sessionId: string;
addedAt: number;
}
/**
* Chat message
*/
export interface ChatMessage {
timestamp: string;
from: string;
content: string;
}
/**
* History entry
*/
export interface GroupChatHistoryEntry {
id: string;
timestamp: number;
summary: string;
participantName: string;
participantColor: string;
type: 'delegation' | 'response' | 'synthesis' | 'error';
elapsedTimeMs?: number;
tokenCount?: number;
cost?: number;
fullResponse?: string;
}
/**
* Moderator usage stats
*/
export interface ModeratorUsage {
contextUsage: number;
totalCost: number;
tokenCount: number;
}
/**
* Creates the Group Chat API object for preload exposure
*/
export function createGroupChatApi() {
return {
// Storage
create: (name: string, moderatorAgentId: string, moderatorConfig?: ModeratorConfig) =>
ipcRenderer.invoke('groupChat:create', name, moderatorAgentId, moderatorConfig),
list: () => ipcRenderer.invoke('groupChat:list'),
load: (id: string) => ipcRenderer.invoke('groupChat:load', id),
delete: (id: string) => ipcRenderer.invoke('groupChat:delete', id),
rename: (id: string, name: string) => ipcRenderer.invoke('groupChat:rename', id, name),
update: (
id: string,
updates: {
name?: string;
moderatorAgentId?: string;
moderatorConfig?: ModeratorConfig;
}
) => ipcRenderer.invoke('groupChat:update', id, updates),
// Chat log
appendMessage: (id: string, from: string, content: string) =>
ipcRenderer.invoke('groupChat:appendMessage', id, from, content),
getMessages: (id: string) => ipcRenderer.invoke('groupChat:getMessages', id),
saveImage: (id: string, imageData: string, filename: string) =>
ipcRenderer.invoke('groupChat:saveImage', id, imageData, filename),
// Moderator
startModerator: (id: string) => ipcRenderer.invoke('groupChat:startModerator', id),
sendToModerator: (id: string, message: string, images?: string[], readOnly?: boolean) =>
ipcRenderer.invoke('groupChat:sendToModerator', id, message, images, readOnly),
stopModerator: (id: string) => ipcRenderer.invoke('groupChat:stopModerator', id),
getModeratorSessionId: (id: string) =>
ipcRenderer.invoke('groupChat:getModeratorSessionId', id),
// Participants
addParticipant: (id: string, name: string, agentId: string, cwd?: string) =>
ipcRenderer.invoke('groupChat:addParticipant', id, name, agentId, cwd),
sendToParticipant: (id: string, name: string, message: string, images?: string[]) =>
ipcRenderer.invoke('groupChat:sendToParticipant', id, name, message, images),
removeParticipant: (id: string, name: string) =>
ipcRenderer.invoke('groupChat:removeParticipant', id, name),
resetParticipantContext: (
id: string,
name: string,
cwd?: string
): Promise<{ newAgentSessionId: string }> =>
ipcRenderer.invoke('groupChat:resetParticipantContext', id, name, cwd),
// History
getHistory: (id: string) => ipcRenderer.invoke('groupChat:getHistory', id),
addHistoryEntry: (id: string, entry: Omit<GroupChatHistoryEntry, 'id'>) =>
ipcRenderer.invoke('groupChat:addHistoryEntry', id, entry),
deleteHistoryEntry: (groupChatId: string, entryId: string) =>
ipcRenderer.invoke('groupChat:deleteHistoryEntry', groupChatId, entryId),
clearHistory: (id: string) => ipcRenderer.invoke('groupChat:clearHistory', id),
getHistoryFilePath: (id: string) => ipcRenderer.invoke('groupChat:getHistoryFilePath', id),
// Export
getImages: (id: string): Promise<Record<string, string>> =>
ipcRenderer.invoke('groupChat:getImages', id),
// Events
onMessage: (callback: (groupChatId: string, message: ChatMessage) => void) => {
const handler = (_: any, groupChatId: string, message: ChatMessage) =>
callback(groupChatId, message);
ipcRenderer.on('groupChat:message', handler);
return () => ipcRenderer.removeListener('groupChat:message', handler);
},
onStateChange: (
callback: (
groupChatId: string,
state: 'idle' | 'moderator-thinking' | 'agent-working'
) => void
) => {
const handler = (
_: any,
groupChatId: string,
state: 'idle' | 'moderator-thinking' | 'agent-working'
) => callback(groupChatId, state);
ipcRenderer.on('groupChat:stateChange', handler);
return () => ipcRenderer.removeListener('groupChat:stateChange', handler);
},
onParticipantsChanged: (
callback: (groupChatId: string, participants: Participant[]) => void
) => {
const handler = (_: any, groupChatId: string, participants: Participant[]) =>
callback(groupChatId, participants);
ipcRenderer.on('groupChat:participantsChanged', handler);
return () => ipcRenderer.removeListener('groupChat:participantsChanged', handler);
},
onModeratorUsage: (callback: (groupChatId: string, usage: ModeratorUsage) => void) => {
const handler = (_: any, groupChatId: string, usage: ModeratorUsage) =>
callback(groupChatId, usage);
ipcRenderer.on('groupChat:moderatorUsage', handler);
return () => ipcRenderer.removeListener('groupChat:moderatorUsage', handler);
},
onHistoryEntry: (callback: (groupChatId: string, entry: GroupChatHistoryEntry) => void) => {
const handler = (_: any, groupChatId: string, entry: GroupChatHistoryEntry) =>
callback(groupChatId, entry);
ipcRenderer.on('groupChat:historyEntry', handler);
return () => ipcRenderer.removeListener('groupChat:historyEntry', handler);
},
onParticipantState: (
callback: (groupChatId: string, participantName: string, state: 'idle' | 'working') => void
) => {
const handler = (
_: any,
groupChatId: string,
participantName: string,
state: 'idle' | 'working'
) => callback(groupChatId, participantName, state);
ipcRenderer.on('groupChat:participantState', handler);
return () => ipcRenderer.removeListener('groupChat:participantState', handler);
},
onModeratorSessionIdChanged: (callback: (groupChatId: string, sessionId: string) => void) => {
const handler = (_: any, groupChatId: string, sessionId: string) =>
callback(groupChatId, sessionId);
ipcRenderer.on('groupChat:moderatorSessionIdChanged', handler);
return () => ipcRenderer.removeListener('groupChat:moderatorSessionIdChanged', handler);
},
};
}
export type GroupChatApi = ReturnType<typeof createGroupChatApi>;

407
src/main/preload/index.ts Normal file
View File

@@ -0,0 +1,407 @@
/**
* Electron Preload Script
*
* This script runs in the renderer process before any web content is loaded.
* It exposes a safe subset of Electron and Node.js APIs to the renderer via contextBridge.
*
* All APIs are organized in modular files within this directory for maintainability.
*/
import { contextBridge } from 'electron';
// Import all factory functions for contextBridge exposure
import {
createSettingsApi,
createSessionsApi,
createGroupsApi,
createAgentErrorApi,
} from './settings';
import { createContextApi } from './context';
import { createWebApi, createWebserverApi, createLiveApi } from './web';
import {
createDialogApi,
createFontsApi,
createShellsApi,
createShellApi,
createTunnelApi,
createSyncApi,
createDevtoolsApi,
createPowerApi,
createUpdatesApi,
createAppApi,
} from './system';
import { createSshRemoteApi } from './sshRemote';
import { createLoggerApi } from './logger';
import { createClaudeApi, createAgentSessionsApi } from './sessions';
import { createTempfileApi, createHistoryApi, createCliApi } from './files';
import { createSpeckitApi, createOpenspecApi } from './commands';
import { createAutorunApi, createPlaybooksApi, createMarketplaceApi } from './autorun';
import { createDebugApi, createDocumentGraphApi } from './debug';
import { createGroupChatApi } from './groupChat';
import { createStatsApi } from './stats';
import { createNotificationApi } from './notifications';
import { createLeaderboardApi } from './leaderboard';
import { createAttachmentsApi } from './attachments';
import { createProcessApi } from './process';
import { createGitApi } from './git';
import { createFsApi } from './fs';
import { createAgentsApi } from './agents';
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('maestro', {
// Settings API
settings: createSettingsApi(),
// Sessions persistence API
sessions: createSessionsApi(),
// Groups persistence API
groups: createGroupsApi(),
// Process/Session API
process: createProcessApi(),
// Agent Error Handling API
agentError: createAgentErrorApi(),
// Context Merge API
context: createContextApi(),
// Web interface API
web: createWebApi(),
// Git API
git: createGitApi(),
// File System API
fs: createFsApi(),
// Web Server API
webserver: createWebserverApi(),
// Live Session API
live: createLiveApi(),
// Agent API
agents: createAgentsApi(),
// Dialog API
dialog: createDialogApi(),
// Font API
fonts: createFontsApi(),
// Shells API (terminal shells)
shells: createShellsApi(),
// Shell API
shell: createShellApi(),
// Tunnel API (Cloudflare)
tunnel: createTunnelApi(),
// SSH Remote API
sshRemote: createSshRemoteApi(),
// Sync API
sync: createSyncApi(),
// DevTools API
devtools: createDevtoolsApi(),
// Power Management API
power: createPowerApi(),
// Updates API
updates: createUpdatesApi(),
// Logger API
logger: createLoggerApi(),
// Claude Code sessions API (DEPRECATED)
claude: createClaudeApi(),
// Agent Sessions API (preferred)
agentSessions: createAgentSessionsApi(),
// Temp file API
tempfile: createTempfileApi(),
// History API
history: createHistoryApi(),
// CLI activity API
cli: createCliApi(),
// Spec Kit API
speckit: createSpeckitApi(),
// OpenSpec API
openspec: createOpenspecApi(),
// Notification API
notification: createNotificationApi(),
// Attachments API
attachments: createAttachmentsApi(),
// Auto Run API
autorun: createAutorunApi(),
// Playbooks API
playbooks: createPlaybooksApi(),
// Marketplace API
marketplace: createMarketplaceApi(),
// Debug Package API
debug: createDebugApi(),
// Document Graph API
documentGraph: createDocumentGraphApi(),
// Group Chat API
groupChat: createGroupChatApi(),
// App lifecycle API
app: createAppApi(),
// Stats API
stats: createStatsApi(),
// Leaderboard API
leaderboard: createLeaderboardApi(),
});
// Re-export factory functions for external consumers (e.g., tests)
export {
// Settings and persistence
createSettingsApi,
createSessionsApi,
createGroupsApi,
createAgentErrorApi,
// Context
createContextApi,
// Web interface
createWebApi,
createWebserverApi,
createLiveApi,
// System utilities
createDialogApi,
createFontsApi,
createShellsApi,
createShellApi,
createTunnelApi,
createSyncApi,
createDevtoolsApi,
createPowerApi,
createUpdatesApi,
createAppApi,
// SSH Remote
createSshRemoteApi,
// Logger
createLoggerApi,
// Sessions
createClaudeApi,
createAgentSessionsApi,
// Files
createTempfileApi,
createHistoryApi,
createCliApi,
// Commands
createSpeckitApi,
createOpenspecApi,
// Auto Run
createAutorunApi,
createPlaybooksApi,
createMarketplaceApi,
// Debug
createDebugApi,
createDocumentGraphApi,
// Group Chat
createGroupChatApi,
// Stats
createStatsApi,
// Notifications
createNotificationApi,
// Leaderboard
createLeaderboardApi,
// Attachments
createAttachmentsApi,
// Process
createProcessApi,
// Git
createGitApi,
// Filesystem
createFsApi,
// Agents
createAgentsApi,
};
// Re-export types for TypeScript consumers
export type {
// From settings
SettingsApi,
SessionsApi,
GroupsApi,
AgentErrorApi,
} from './settings';
export type {
// From context
ContextApi,
StoredMessage,
StoredSessionResponse,
} from './context';
export type {
// From web
WebApi,
WebserverApi,
LiveApi,
AutoRunState,
AiTabState,
} from './web';
export type {
// From system
DialogApi,
FontsApi,
ShellsApi,
ShellApi,
TunnelApi,
SyncApi,
DevtoolsApi,
PowerApi,
UpdatesApi,
AppApi,
ShellInfo,
UpdateStatus,
} from './system';
export type {
// From sshRemote
SshRemoteApi,
SshRemoteConfig,
SshConfigHost,
} from './sshRemote';
export type {
// From logger
LoggerApi,
} from './logger';
export type {
// From sessions
ClaudeApi,
AgentSessionsApi,
NamedSessionEntry,
NamedSessionEntryWithAgent,
GlobalStatsUpdate,
} from './sessions';
export type {
// From files
TempfileApi,
HistoryApi,
CliApi,
HistoryEntry,
} from './files';
export type {
// From commands
SpeckitApi,
OpenspecApi,
CommandMetadata,
CommandDefinition,
} from './commands';
export type {
// From autorun
AutorunApi,
PlaybooksApi,
MarketplaceApi,
Playbook,
PlaybookDocument,
WorktreeSettings,
} from './autorun';
export type {
// From debug
DebugApi,
DocumentGraphApi,
DebugPackageOptions,
DocumentGraphChange,
} from './debug';
export type {
// From groupChat
GroupChatApi,
ModeratorConfig,
Participant,
ChatMessage,
GroupChatHistoryEntry,
ModeratorUsage,
} from './groupChat';
export type {
// From stats
StatsApi,
QueryEvent,
AutoRunSession,
AutoRunTask,
SessionCreatedEvent,
StatsAggregation,
} from './stats';
export type {
// From notifications
NotificationApi,
NotificationShowResponse,
TtsResponse,
} from './notifications';
export type {
// From leaderboard
LeaderboardApi,
LeaderboardSubmitData,
LeaderboardSubmitResponse,
AuthStatusResponse,
ResendConfirmationResponse,
LeaderboardEntry,
LongestRunEntry,
LeaderboardGetResponse,
LongestRunsGetResponse,
LeaderboardSyncResponse,
} from './leaderboard';
export type {
// From attachments
AttachmentsApi,
AttachmentResponse,
AttachmentLoadResponse,
AttachmentListResponse,
AttachmentPathResponse,
} from './attachments';
export type {
// From process
ProcessApi,
ProcessConfig,
ProcessSpawnResponse,
RunCommandConfig,
ActiveProcess,
UsageStats,
AgentError,
ToolExecutionEvent,
SshRemoteInfo,
} from './process';
export type {
// From git
GitApi,
WorktreeInfo,
WorktreeEntry,
GitSubdirEntry,
GitLogEntry,
WorktreeDiscoveredData,
} from './git';
export type {
// From fs
FsApi,
DirectoryEntry,
FileStat,
DirectorySizeInfo,
ItemCountInfo,
} from './fs';
export type {
// From agents
AgentsApi,
AgentCapabilities,
AgentConfig,
AgentRefreshResult,
} from './agents';

View File

@@ -0,0 +1,221 @@
/**
* Preload API for leaderboard
*
* Provides the window.maestro.leaderboard namespace for:
* - Getting installation ID
* - Submitting leaderboard entries
* - Polling auth status after email confirmation
* - Resending confirmation emails
* - Fetching leaderboard data
* - Syncing stats from server
*/
import { ipcRenderer } from 'electron';
/**
* Data submitted when creating/updating a leaderboard entry
*/
export interface LeaderboardSubmitData {
email: string;
displayName: string;
githubUsername?: string;
twitterHandle?: string;
linkedinHandle?: string;
discordUsername?: string;
blueskyHandle?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
longestRunMs?: number;
longestRunDate?: string;
currentRunMs?: number;
theme?: string;
clientToken?: string;
authToken?: string;
deltaMs?: number;
deltaRuns?: number;
installationId?: string;
clientTotalTimeMs?: number;
}
/**
* Response from leaderboard submission
*/
export interface LeaderboardSubmitResponse {
success: boolean;
message: string;
pendingEmailConfirmation?: boolean;
error?: string;
authTokenRequired?: boolean;
ranking?: {
cumulative: {
rank: number;
total: number;
previousRank: number | null;
improved: boolean;
};
longestRun: {
rank: number;
total: number;
previousRank: number | null;
improved: boolean;
} | null;
};
serverTotals?: {
cumulativeTimeMs: number;
totalRuns: number;
};
}
/**
* Response from polling auth status
*/
export interface AuthStatusResponse {
status: 'pending' | 'confirmed' | 'expired' | 'error';
authToken?: string;
message?: string;
error?: string;
}
/**
* Response from resending confirmation
*/
export interface ResendConfirmationResponse {
success: boolean;
message?: string;
error?: string;
}
/**
* Leaderboard entry for cumulative time rankings
*/
export interface LeaderboardEntry {
rank: number;
displayName: string;
githubUsername?: string;
avatarUrl?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
}
/**
* Leaderboard entry for longest run rankings
*/
export interface LongestRunEntry {
rank: number;
displayName: string;
githubUsername?: string;
avatarUrl?: string;
longestRunMs: number;
runDate: string;
}
/**
* Response from fetching leaderboard
*/
export interface LeaderboardGetResponse {
success: boolean;
entries?: LeaderboardEntry[];
error?: string;
}
/**
* Response from fetching longest runs leaderboard
*/
export interface LongestRunsGetResponse {
success: boolean;
entries?: LongestRunEntry[];
error?: string;
}
/**
* Response from syncing stats
*/
export interface LeaderboardSyncResponse {
success: boolean;
found: boolean;
message?: string;
error?: string;
errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS';
data?: {
displayName: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
longestRunMs: number | null;
longestRunDate: string | null;
keyboardLevel: number | null;
coveragePercent: number | null;
ranking: {
cumulative: { rank: number; total: number };
longestRun: { rank: number; total: number } | null;
};
};
}
/**
* Creates the leaderboard API object for preload exposure
*/
export function createLeaderboardApi() {
return {
/**
* Get the unique installation ID for this Maestro installation
*/
getInstallationId: (): Promise<string | null> =>
ipcRenderer.invoke('leaderboard:getInstallationId'),
/**
* Submit leaderboard entry to runmaestro.ai
* @param data - Leaderboard submission data
*/
submit: (data: LeaderboardSubmitData): Promise<LeaderboardSubmitResponse> =>
ipcRenderer.invoke('leaderboard:submit', data),
/**
* Poll for auth token after email confirmation
* @param clientToken - Client token from initial submission
*/
pollAuthStatus: (clientToken: string): Promise<AuthStatusResponse> =>
ipcRenderer.invoke('leaderboard:pollAuthStatus', clientToken),
/**
* Resend confirmation email (self-service auth token recovery)
* @param data - Email and client token
*/
resendConfirmation: (data: {
email: string;
clientToken: string;
}): Promise<ResendConfirmationResponse> =>
ipcRenderer.invoke('leaderboard:resendConfirmation', data),
/**
* Get leaderboard entries (cumulative time rankings)
* @param options - Optional limit (default: 50)
*/
get: (options?: { limit?: number }): Promise<LeaderboardGetResponse> =>
ipcRenderer.invoke('leaderboard:get', options),
/**
* Get longest runs leaderboard
* @param options - Optional limit (default: 50)
*/
getLongestRuns: (options?: { limit?: number }): Promise<LongestRunsGetResponse> =>
ipcRenderer.invoke('leaderboard:getLongestRuns', options),
/**
* Sync user stats from server (for new device installations)
* @param data - Email and auth token
*/
sync: (data: { email: string; authToken: string }): Promise<LeaderboardSyncResponse> =>
ipcRenderer.invoke('leaderboard:sync', data),
};
}
/**
* TypeScript type for the leaderboard API
*/
export type LeaderboardApi = ReturnType<typeof createLeaderboardApi>;

View File

@@ -0,0 +1,58 @@
/**
* Preload API for logging operations
*
* Provides the window.maestro.logger namespace for:
* - Application logging
* - Log level management
* - Real-time log subscriptions
*/
import { ipcRenderer } from 'electron';
import type { MainLogLevel, SystemLogEntry } from '../../shared/logger-types';
/**
* Creates the logger API object for preload exposure
*/
export function createLoggerApi() {
return {
log: (level: MainLogLevel, message: string, context?: string, data?: unknown) =>
ipcRenderer.invoke('logger:log', level, message, context, data),
getLogs: (filter?: { level?: MainLogLevel; context?: string; limit?: number }) =>
ipcRenderer.invoke('logger:getLogs', filter),
clearLogs: () => ipcRenderer.invoke('logger:clearLogs'),
setLogLevel: (level: MainLogLevel) => ipcRenderer.invoke('logger:setLogLevel', level),
getLogLevel: () => ipcRenderer.invoke('logger:getLogLevel'),
setMaxLogBuffer: (max: number) => ipcRenderer.invoke('logger:setMaxLogBuffer', max),
getMaxLogBuffer: () => ipcRenderer.invoke('logger:getMaxLogBuffer'),
// Convenience method for logging toast notifications
toast: (title: string, data?: unknown) =>
ipcRenderer.invoke('logger:log', 'toast', title, 'Toast', data),
// Convenience method for Auto Run workflow logging (cannot be turned off)
autorun: (message: string, context?: string, data?: unknown) =>
ipcRenderer.invoke('logger:log', 'autorun', message, context || 'AutoRun', data),
// Subscribe to new log entries in real-time
onNewLog: (callback: (log: SystemLogEntry) => void) => {
const handler = (_: Electron.IpcRendererEvent, log: SystemLogEntry) => callback(log);
ipcRenderer.on('logger:newLog', handler);
return () => ipcRenderer.removeListener('logger:newLog', handler);
},
// File logging (enabled by default on Windows for debugging)
getLogFilePath: (): Promise<string> => ipcRenderer.invoke('logger:getLogFilePath'),
isFileLoggingEnabled: (): Promise<boolean> => ipcRenderer.invoke('logger:isFileLoggingEnabled'),
enableFileLogging: (): Promise<void> => ipcRenderer.invoke('logger:enableFileLogging'),
};
}
export type LoggerApi = ReturnType<typeof createLoggerApi>;

View File

@@ -0,0 +1,73 @@
/**
* Preload API for notifications
*
* Provides the window.maestro.notification namespace for:
* - Showing OS notifications
* - Text-to-speech (TTS) functionality
* - TTS completion events
*/
import { ipcRenderer } from 'electron';
/**
* Response from showing a notification
*/
export interface NotificationShowResponse {
success: boolean;
error?: string;
}
/**
* Response from TTS operations
*/
export interface TtsResponse {
success: boolean;
ttsId?: number;
error?: string;
}
/**
* Creates the notification API object for preload exposure
*/
export function createNotificationApi() {
return {
/**
* Show an OS notification
* @param title - Notification title
* @param body - Notification body text
*/
show: (title: string, body: string): Promise<NotificationShowResponse> =>
ipcRenderer.invoke('notification:show', title, body),
/**
* Speak text using system TTS
* @param text - Text to speak
* @param command - Optional TTS command (default: 'say' on macOS)
*/
speak: (text: string, command?: string): Promise<TtsResponse> =>
ipcRenderer.invoke('notification:speak', text, command),
/**
* Stop a running TTS process
* @param ttsId - ID of the TTS process to stop
*/
stopSpeak: (ttsId: number): Promise<TtsResponse> =>
ipcRenderer.invoke('notification:stopSpeak', ttsId),
/**
* Subscribe to TTS completion events
* @param handler - Callback when a TTS process completes
* @returns Cleanup function to unsubscribe
*/
onTtsCompleted: (handler: (ttsId: number) => void): (() => void) => {
const wrappedHandler = (_event: Electron.IpcRendererEvent, ttsId: number) => handler(ttsId);
ipcRenderer.on('tts:completed', wrappedHandler);
return () => ipcRenderer.removeListener('tts:completed', wrappedHandler);
},
};
}
/**
* TypeScript type for the notification API
*/
export type NotificationApi = ReturnType<typeof createNotificationApi>;

420
src/main/preload/process.ts Normal file
View File

@@ -0,0 +1,420 @@
/**
* Preload API for process management
*
* Provides the window.maestro.process namespace for:
* - Spawning and managing agent/terminal processes
* - Writing to processes
* - Handling process events (data, exit, errors)
* - Remote command execution from web interface
* - SSH remote execution support
*/
import { ipcRenderer } from 'electron';
/**
* Configuration for spawning a process
*/
export interface ProcessConfig {
sessionId: string;
toolType: string;
cwd: string;
command: string;
args: string[];
prompt?: string;
shell?: string;
images?: string[]; // Base64 data URLs for images
// Agent-specific spawn options (used to build args via agent config)
agentSessionId?: string; // For session resume (uses agent's resumeArgs builder)
readOnlyMode?: boolean; // For read-only/plan mode (uses agent's readOnlyArgs)
modelId?: string; // For model selection (uses agent's modelArgs builder)
yoloMode?: boolean; // For YOLO/full-access mode (uses agent's yoloModeArgs)
// Stats tracking options
querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run
tabId?: string; // Tab ID for multi-tab tracking
}
/**
* Response from spawning a process
*/
export interface ProcessSpawnResponse {
pid: number;
success: boolean;
sshRemote?: { id: string; name: string; host: string };
}
/**
* Configuration for running a single command
*/
export interface RunCommandConfig {
sessionId: string;
command: string;
cwd: string;
shell?: string;
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}
/**
* Active process information
*/
export interface ActiveProcess {
sessionId: string;
toolType: string;
pid: number;
cwd: string;
isTerminal: boolean;
isBatchMode: boolean;
startTime: number;
command?: string;
args?: string[];
}
/**
* Usage statistics from AI responses
*/
export interface UsageStats {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
totalCostUsd: number;
contextWindow: number;
reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini)
}
/**
* Agent error information
*/
export interface AgentError {
type: string;
message: string;
recoverable: boolean;
agentId: string;
sessionId?: string;
timestamp: number;
raw?: {
exitCode?: number;
stderr?: string;
stdout?: string;
errorLine?: string;
};
}
/**
* Tool execution event
*/
export interface ToolExecutionEvent {
toolName: string;
state?: unknown;
timestamp: number;
}
/**
* SSH remote info
*/
export interface SshRemoteInfo {
id: string;
name: string;
host: string;
}
/**
* Creates the process API object for preload exposure
*/
export function createProcessApi() {
return {
/**
* Spawn a new process (agent or terminal)
*/
spawn: (config: ProcessConfig): Promise<ProcessSpawnResponse> =>
ipcRenderer.invoke('process:spawn', config),
/**
* Write data to a process stdin
*/
write: (sessionId: string, data: string): Promise<boolean> =>
ipcRenderer.invoke('process:write', sessionId, data),
/**
* Send interrupt signal (Ctrl+C) to a process
*/
interrupt: (sessionId: string): Promise<boolean> =>
ipcRenderer.invoke('process:interrupt', sessionId),
/**
* Kill a process
*/
kill: (sessionId: string): Promise<boolean> => ipcRenderer.invoke('process:kill', sessionId),
/**
* Resize process terminal
*/
resize: (sessionId: string, cols: number, rows: number): Promise<boolean> =>
ipcRenderer.invoke('process:resize', sessionId, cols, rows),
/**
* Run a single command and capture only stdout/stderr (no PTY echo/prompts)
* Supports SSH remote execution when sessionSshRemoteConfig is provided
*/
runCommand: (config: RunCommandConfig): Promise<{ exitCode: number }> =>
ipcRenderer.invoke('process:runCommand', config),
/**
* Get all active processes from ProcessManager
*/
getActiveProcesses: (): Promise<ActiveProcess[]> =>
ipcRenderer.invoke('process:getActiveProcesses'),
// Event listeners
/**
* Subscribe to process data output
*/
onData: (callback: (sessionId: string, data: string) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, data: string) => callback(sessionId, data);
ipcRenderer.on('process:data', handler);
return () => ipcRenderer.removeListener('process:data', handler);
},
/**
* Subscribe to process exit events
*/
onExit: (callback: (sessionId: string, code: number) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, code: number) => callback(sessionId, code);
ipcRenderer.on('process:exit', handler);
return () => ipcRenderer.removeListener('process:exit', handler);
},
/**
* Subscribe to agent session ID events
*/
onSessionId: (callback: (sessionId: string, agentSessionId: string) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, agentSessionId: string) =>
callback(sessionId, agentSessionId);
ipcRenderer.on('process:session-id', handler);
return () => ipcRenderer.removeListener('process:session-id', handler);
},
/**
* Subscribe to slash commands discovered from agent
*/
onSlashCommands: (
callback: (sessionId: string, slashCommands: string[]) => void
): (() => void) => {
const handler = (_: unknown, sessionId: string, slashCommands: string[]) =>
callback(sessionId, slashCommands);
ipcRenderer.on('process:slash-commands', handler);
return () => ipcRenderer.removeListener('process:slash-commands', handler);
},
/**
* Subscribe to thinking/streaming content chunks from AI agents
* Emitted when agents produce partial text events (isPartial: true)
* Renderer decides whether to display based on tab's showThinking setting
*/
onThinkingChunk: (callback: (sessionId: string, content: string) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, content: string) =>
callback(sessionId, content);
ipcRenderer.on('process:thinking-chunk', handler);
return () => ipcRenderer.removeListener('process:thinking-chunk', handler);
},
/**
* Subscribe to tool execution events
*/
onToolExecution: (
callback: (sessionId: string, toolEvent: ToolExecutionEvent) => void
): (() => void) => {
const handler = (_: unknown, sessionId: string, toolEvent: ToolExecutionEvent) =>
callback(sessionId, toolEvent);
ipcRenderer.on('process:tool-execution', handler);
return () => ipcRenderer.removeListener('process:tool-execution', handler);
},
/**
* Subscribe to SSH remote execution status
* Emitted when a process starts executing via SSH on a remote host
*/
onSshRemote: (
callback: (sessionId: string, sshRemote: SshRemoteInfo | null) => void
): (() => void) => {
const handler = (_: unknown, sessionId: string, sshRemote: SshRemoteInfo | null) =>
callback(sessionId, sshRemote);
ipcRenderer.on('process:ssh-remote', handler);
return () => ipcRenderer.removeListener('process:ssh-remote', handler);
},
/**
* Subscribe to remote command execution from web interface
* This allows web commands to go through the same code path as desktop commands
* inputMode is optional - if provided, renderer should use it instead of session state
*/
onRemoteCommand: (
callback: (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => void
): (() => void) => {
console.log(
'[Preload] Registering onRemoteCommand listener, callback type:',
typeof callback
);
const handler = (
_: unknown,
sessionId: string,
command: string,
inputMode?: 'ai' | 'terminal'
) => {
console.log('[Preload] Received remote:executeCommand IPC:', {
sessionId,
command: command?.substring(0, 50),
inputMode,
});
console.log('[Preload] About to invoke callback, callback type:', typeof callback);
try {
callback(sessionId, command, inputMode);
console.log('[Preload] Callback invoked successfully');
} catch (error) {
console.error('[Preload] Error invoking remote command callback:', error);
}
};
ipcRenderer.on('remote:executeCommand', handler);
return () => ipcRenderer.removeListener('remote:executeCommand', handler);
},
/**
* Subscribe to remote mode switch from web interface
* Forwards to desktop's toggleInputMode logic
*/
onRemoteSwitchMode: (
callback: (sessionId: string, mode: 'ai' | 'terminal') => void
): (() => void) => {
console.log('[Preload] Registering onRemoteSwitchMode listener');
const handler = (_: unknown, sessionId: string, mode: 'ai' | 'terminal') => {
console.log('[Preload] Received remote:switchMode IPC:', { sessionId, mode });
callback(sessionId, mode);
};
ipcRenderer.on('remote:switchMode', handler);
return () => ipcRenderer.removeListener('remote:switchMode', handler);
},
/**
* Subscribe to remote interrupt from web interface
* Forwards to desktop's handleInterrupt logic
*/
onRemoteInterrupt: (callback: (sessionId: string) => void): (() => void) => {
const handler = (_: unknown, sessionId: string) => callback(sessionId);
ipcRenderer.on('remote:interrupt', handler);
return () => ipcRenderer.removeListener('remote:interrupt', handler);
},
/**
* Subscribe to remote session selection from web interface
* Forwards to desktop's setActiveSessionId logic
* Optional tabId to also switch to a specific tab within the session
*/
onRemoteSelectSession: (
callback: (sessionId: string, tabId?: string) => void
): (() => void) => {
console.log('[Preload] Registering onRemoteSelectSession listener');
const handler = (_: unknown, sessionId: string, tabId?: string) => {
console.log('[Preload] Received remote:selectSession IPC:', { sessionId, tabId });
callback(sessionId, tabId);
};
ipcRenderer.on('remote:selectSession', handler);
return () => ipcRenderer.removeListener('remote:selectSession', handler);
},
/**
* Subscribe to remote tab selection from web interface
*/
onRemoteSelectTab: (callback: (sessionId: string, tabId: string) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, tabId: string) => callback(sessionId, tabId);
ipcRenderer.on('remote:selectTab', handler);
return () => ipcRenderer.removeListener('remote:selectTab', handler);
},
/**
* Subscribe to remote new tab from web interface
*/
onRemoteNewTab: (
callback: (sessionId: string, responseChannel: string) => void
): (() => void) => {
const handler = (_: unknown, sessionId: string, responseChannel: string) =>
callback(sessionId, responseChannel);
ipcRenderer.on('remote:newTab', handler);
return () => ipcRenderer.removeListener('remote:newTab', handler);
},
/**
* Send response for remote new tab
*/
sendRemoteNewTabResponse: (responseChannel: string, result: { tabId: string } | null): void => {
ipcRenderer.send(responseChannel, result);
},
/**
* Subscribe to remote close tab from web interface
*/
onRemoteCloseTab: (callback: (sessionId: string, tabId: string) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, tabId: string) => callback(sessionId, tabId);
ipcRenderer.on('remote:closeTab', handler);
return () => ipcRenderer.removeListener('remote:closeTab', handler);
},
/**
* Subscribe to remote rename tab from web interface
*/
onRemoteRenameTab: (
callback: (sessionId: string, tabId: string, newName: string) => void
): (() => void) => {
const handler = (_: unknown, sessionId: string, tabId: string, newName: string) =>
callback(sessionId, tabId, newName);
ipcRenderer.on('remote:renameTab', handler);
return () => ipcRenderer.removeListener('remote:renameTab', handler);
},
/**
* Subscribe to stderr from runCommand (separate stream)
*/
onStderr: (callback: (sessionId: string, data: string) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, data: string) => callback(sessionId, data);
ipcRenderer.on('process:stderr', handler);
return () => ipcRenderer.removeListener('process:stderr', handler);
},
/**
* Subscribe to command exit from runCommand (separate from PTY exit)
*/
onCommandExit: (callback: (sessionId: string, code: number) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, code: number) => callback(sessionId, code);
ipcRenderer.on('process:command-exit', handler);
return () => ipcRenderer.removeListener('process:command-exit', handler);
},
/**
* Subscribe to usage statistics from AI responses
*/
onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, usageStats: UsageStats) =>
callback(sessionId, usageStats);
ipcRenderer.on('process:usage', handler);
return () => ipcRenderer.removeListener('process:usage', handler);
},
/**
* Subscribe to agent error events (auth expired, token exhaustion, rate limits, etc.)
*/
onAgentError: (callback: (sessionId: string, error: AgentError) => void): (() => void) => {
const handler = (_: unknown, sessionId: string, error: AgentError) =>
callback(sessionId, error);
ipcRenderer.on('agent:error', handler);
return () => ipcRenderer.removeListener('agent:error', handler);
},
};
}
/**
* TypeScript type for the process API
*/
export type ProcessApi = ReturnType<typeof createProcessApi>;

View File

@@ -0,0 +1,352 @@
/**
* Preload API for agent sessions
*
* Provides the window.maestro.claude and window.maestro.agentSessions namespaces for:
* - Claude Code session storage (deprecated)
* - Generic multi-agent session storage
*/
import { ipcRenderer } from 'electron';
// Helper to log deprecation warnings
const logDeprecationWarning = (method: string, replacement?: string) => {
const message = replacement
? `[Deprecation Warning] window.maestro.claude.${method}() is deprecated. Use window.maestro.agentSessions.${replacement}() instead.`
: `[Deprecation Warning] window.maestro.claude.${method}() is deprecated. Use the agentSessions API instead.`;
console.warn(message);
};
/**
* Named session entry
*/
export interface NamedSessionEntry {
agentSessionId: string;
projectPath: string;
sessionName: string;
starred?: boolean;
lastActivityAt?: number;
}
/**
* Named session entry with agent ID
*/
export interface NamedSessionEntryWithAgent extends NamedSessionEntry {
agentId: string;
}
/**
* Global stats update
*/
export interface GlobalStatsUpdate {
totalSessions: number;
totalMessages: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCacheReadTokens: number;
totalCacheCreationTokens: number;
totalCostUsd: number;
hasCostData: boolean;
totalSizeBytes: number;
isComplete: boolean;
byProvider: Record<
string,
{
sessions: number;
messages: number;
inputTokens: number;
outputTokens: number;
costUsd: number;
hasCostData: boolean;
}
>;
}
/**
* Creates the Claude Code sessions API object (DEPRECATED)
*/
export function createClaudeApi() {
return {
listSessions: (projectPath: string) => {
logDeprecationWarning('listSessions', 'list');
return ipcRenderer.invoke('claude:listSessions', projectPath);
},
listSessionsPaginated: (projectPath: string, options?: { cursor?: string; limit?: number }) => {
logDeprecationWarning('listSessionsPaginated', 'listPaginated');
return ipcRenderer.invoke('claude:listSessionsPaginated', projectPath, options);
},
getProjectStats: (projectPath: string) => {
logDeprecationWarning('getProjectStats');
return ipcRenderer.invoke('claude:getProjectStats', projectPath);
},
getSessionTimestamps: (projectPath: string): Promise<{ timestamps: string[] }> => {
logDeprecationWarning('getSessionTimestamps');
return ipcRenderer.invoke('claude:getSessionTimestamps', projectPath);
},
onProjectStatsUpdate: (
callback: (stats: {
projectPath: string;
totalSessions: number;
totalMessages: number;
totalCostUsd: number;
totalSizeBytes: number;
oldestTimestamp: string | null;
processedCount: number;
isComplete: boolean;
}) => void
) => {
logDeprecationWarning('onProjectStatsUpdate');
const handler = (_: any, stats: any) => callback(stats);
ipcRenderer.on('claude:projectStatsUpdate', handler);
return () => ipcRenderer.removeListener('claude:projectStatsUpdate', handler);
},
getGlobalStats: () => {
logDeprecationWarning('getGlobalStats');
return ipcRenderer.invoke('claude:getGlobalStats');
},
onGlobalStatsUpdate: (
callback: (stats: {
totalSessions: number;
totalMessages: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCacheReadTokens: number;
totalCacheCreationTokens: number;
totalCostUsd: number;
totalSizeBytes: number;
isComplete: boolean;
}) => void
) => {
logDeprecationWarning('onGlobalStatsUpdate');
const handler = (_: any, stats: any) => callback(stats);
ipcRenderer.on('claude:globalStatsUpdate', handler);
return () => ipcRenderer.removeListener('claude:globalStatsUpdate', handler);
},
readSessionMessages: (
projectPath: string,
sessionId: string,
options?: { offset?: number; limit?: number }
) => {
logDeprecationWarning('readSessionMessages', 'read');
return ipcRenderer.invoke('claude:readSessionMessages', projectPath, sessionId, options);
},
searchSessions: (
projectPath: string,
query: string,
searchMode: 'title' | 'user' | 'assistant' | 'all'
) => {
logDeprecationWarning('searchSessions', 'search');
return ipcRenderer.invoke('claude:searchSessions', projectPath, query, searchMode);
},
getCommands: (projectPath: string) => {
logDeprecationWarning('getCommands');
return ipcRenderer.invoke('claude:getCommands', projectPath);
},
registerSessionOrigin: (
projectPath: string,
agentSessionId: string,
origin: 'user' | 'auto',
sessionName?: string
) => {
logDeprecationWarning('registerSessionOrigin');
return ipcRenderer.invoke(
'claude:registerSessionOrigin',
projectPath,
agentSessionId,
origin,
sessionName
);
},
updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => {
logDeprecationWarning('updateSessionName');
return ipcRenderer.invoke(
'claude:updateSessionName',
projectPath,
agentSessionId,
sessionName
);
},
updateSessionStarred: (projectPath: string, agentSessionId: string, starred: boolean) => {
logDeprecationWarning('updateSessionStarred');
return ipcRenderer.invoke(
'claude:updateSessionStarred',
projectPath,
agentSessionId,
starred
);
},
updateSessionContextUsage: (
projectPath: string,
agentSessionId: string,
contextUsage: number
) => {
return ipcRenderer.invoke(
'claude:updateSessionContextUsage',
projectPath,
agentSessionId,
contextUsage
);
},
getSessionOrigins: (projectPath: string) => {
logDeprecationWarning('getSessionOrigins');
return ipcRenderer.invoke('claude:getSessionOrigins', projectPath);
},
getAllNamedSessions: (): Promise<NamedSessionEntry[]> => {
logDeprecationWarning('getAllNamedSessions');
return ipcRenderer.invoke('claude:getAllNamedSessions');
},
deleteMessagePair: (
projectPath: string,
sessionId: string,
userMessageUuid: string,
fallbackContent?: string
) => {
logDeprecationWarning('deleteMessagePair', 'deleteMessagePair');
return ipcRenderer.invoke(
'claude:deleteMessagePair',
projectPath,
sessionId,
userMessageUuid,
fallbackContent
);
},
};
}
/**
* Creates the agent sessions API object (preferred API)
*/
export function createAgentSessionsApi() {
return {
list: (agentId: string, projectPath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('agentSessions:list', agentId, projectPath, sshRemoteId),
listPaginated: (
agentId: string,
projectPath: string,
options?: { cursor?: string; limit?: number },
sshRemoteId?: string
) =>
ipcRenderer.invoke('agentSessions:listPaginated', agentId, projectPath, options, sshRemoteId),
read: (
agentId: string,
projectPath: string,
sessionId: string,
options?: { offset?: number; limit?: number },
sshRemoteId?: string
) =>
ipcRenderer.invoke(
'agentSessions:read',
agentId,
projectPath,
sessionId,
options,
sshRemoteId
),
search: (
agentId: string,
projectPath: string,
query: string,
searchMode: 'title' | 'user' | 'assistant' | 'all',
sshRemoteId?: string
) =>
ipcRenderer.invoke(
'agentSessions:search',
agentId,
projectPath,
query,
searchMode,
sshRemoteId
),
getPath: (agentId: string, projectPath: string, sessionId: string, sshRemoteId?: string) =>
ipcRenderer.invoke('agentSessions:getPath', agentId, projectPath, sessionId, sshRemoteId),
deleteMessagePair: (
agentId: string,
projectPath: string,
sessionId: string,
userMessageUuid: string,
fallbackContent?: string
) =>
ipcRenderer.invoke(
'agentSessions:deleteMessagePair',
agentId,
projectPath,
sessionId,
userMessageUuid,
fallbackContent
),
hasStorage: (agentId: string) => ipcRenderer.invoke('agentSessions:hasStorage', agentId),
getAvailableStorages: () => ipcRenderer.invoke('agentSessions:getAvailableStorages'),
getGlobalStats: () => ipcRenderer.invoke('agentSessions:getGlobalStats'),
getAllNamedSessions: (): Promise<NamedSessionEntryWithAgent[]> =>
ipcRenderer.invoke('agentSessions:getAllNamedSessions'),
onGlobalStatsUpdate: (callback: (stats: GlobalStatsUpdate) => void) => {
const handler = (_: unknown, stats: GlobalStatsUpdate) => callback(stats);
ipcRenderer.on('agentSessions:globalStatsUpdate', handler);
return () => ipcRenderer.removeListener('agentSessions:globalStatsUpdate', handler);
},
registerSessionOrigin: (
projectPath: string,
agentSessionId: string,
origin: 'user' | 'auto',
sessionName?: string
) =>
ipcRenderer.invoke(
'claude:registerSessionOrigin',
projectPath,
agentSessionId,
origin,
sessionName
),
updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) =>
ipcRenderer.invoke('claude:updateSessionName', projectPath, agentSessionId, sessionName),
getOrigins: (
agentId: string,
projectPath: string
): Promise<
Record<string, { origin?: 'user' | 'auto'; sessionName?: string; starred?: boolean }>
> => ipcRenderer.invoke('agentSessions:getOrigins', agentId, projectPath),
setSessionName: (
agentId: string,
projectPath: string,
sessionId: string,
sessionName: string | null
) =>
ipcRenderer.invoke(
'agentSessions:setSessionName',
agentId,
projectPath,
sessionId,
sessionName
),
setSessionStarred: (
agentId: string,
projectPath: string,
sessionId: string,
starred: boolean
) =>
ipcRenderer.invoke(
'agentSessions:setSessionStarred',
agentId,
projectPath,
sessionId,
starred
),
};
}
export type ClaudeApi = ReturnType<typeof createClaudeApi>;
export type AgentSessionsApi = ReturnType<typeof createAgentSessionsApi>;

View File

@@ -0,0 +1,62 @@
/**
* Preload API for settings and persistence
*
* Provides the window.maestro.settings, sessions, and groups namespaces for:
* - Application settings persistence
* - Session list persistence
* - Group list persistence
*/
import { ipcRenderer } from 'electron';
/**
* Creates the settings API object for preload exposure
*/
export function createSettingsApi() {
return {
get: (key: string) => ipcRenderer.invoke('settings:get', key),
set: (key: string, value: unknown) => ipcRenderer.invoke('settings:set', key, value),
getAll: () => ipcRenderer.invoke('settings:getAll'),
};
}
/**
* Creates the sessions persistence API object for preload exposure
*/
export function createSessionsApi() {
return {
getAll: () => ipcRenderer.invoke('sessions:getAll'),
setAll: (sessions: any[]) => ipcRenderer.invoke('sessions:setAll', sessions),
};
}
/**
* Creates the groups persistence API object for preload exposure
*/
export function createGroupsApi() {
return {
getAll: () => ipcRenderer.invoke('groups:getAll'),
setAll: (groups: any[]) => ipcRenderer.invoke('groups:setAll', groups),
};
}
/**
* Creates the agent error handling API object for preload exposure
*/
export function createAgentErrorApi() {
return {
clearError: (sessionId: string) => ipcRenderer.invoke('agent:clearError', sessionId),
retryAfterError: (
sessionId: string,
options?: {
prompt?: string;
newSession?: boolean;
}
) => ipcRenderer.invoke('agent:retryAfterError', sessionId, options),
};
}
export type SettingsApi = ReturnType<typeof createSettingsApi>;
export type SessionsApi = ReturnType<typeof createSessionsApi>;
export type GroupsApi = ReturnType<typeof createGroupsApi>;
export type AgentErrorApi = ReturnType<typeof createAgentErrorApi>;

View File

@@ -0,0 +1,74 @@
/**
* Preload API for SSH remote operations
*
* Provides the window.maestro.sshRemote namespace for:
* - SSH remote configuration management
* - SSH connection testing
* - SSH config file parsing
*/
import { ipcRenderer } from 'electron';
/**
* SSH remote configuration
*/
export interface SshRemoteConfig {
id: string;
name: string;
host: string;
port: number;
username: string;
privateKeyPath: string;
remoteEnv?: Record<string, string>;
enabled: boolean;
}
/**
* SSH config host entry
*/
export interface SshConfigHost {
host: string;
hostName?: string;
port?: number;
user?: string;
identityFile?: string;
proxyJump?: string;
}
/**
* Creates the SSH remote API object for preload exposure
*/
export function createSshRemoteApi() {
return {
saveConfig: (config: {
id?: string;
name?: string;
host?: string;
port?: number;
username?: string;
privateKeyPath?: string;
remoteEnv?: Record<string, string>;
enabled?: boolean;
}) => ipcRenderer.invoke('ssh-remote:saveConfig', config),
deleteConfig: (id: string) => ipcRenderer.invoke('ssh-remote:deleteConfig', id),
getConfigs: () => ipcRenderer.invoke('ssh-remote:getConfigs'),
getDefaultId: () => ipcRenderer.invoke('ssh-remote:getDefaultId'),
setDefaultId: (id: string | null) => ipcRenderer.invoke('ssh-remote:setDefaultId', id),
test: (configOrId: string | SshRemoteConfig, agentCommand?: string) =>
ipcRenderer.invoke('ssh-remote:test', configOrId, agentCommand),
getSshConfigHosts: (): Promise<{
success: boolean;
hosts: SshConfigHost[];
error?: string;
configPath: string;
}> => ipcRenderer.invoke('ssh-remote:getSshConfigHosts'),
};
}
export type SshRemoteApi = ReturnType<typeof createSshRemoteApi>;

207
src/main/preload/stats.ts Normal file
View File

@@ -0,0 +1,207 @@
/**
* Preload API for stats operations
*
* Provides the window.maestro.stats namespace for:
* - Usage tracking and analytics
* - Query event recording
* - Auto Run session tracking
*/
import { ipcRenderer } from 'electron';
/**
* Query event for recording
*/
export interface QueryEvent {
sessionId: string;
agentType: string;
source: 'user' | 'auto';
startTime: number;
duration: number;
projectPath?: string;
tabId?: string;
isRemote?: boolean;
}
/**
* Auto Run session for recording
*/
export interface AutoRunSession {
sessionId: string;
agentType: string;
documentPath?: string;
startTime: number;
tasksTotal?: number;
projectPath?: string;
}
/**
* Auto Run task for recording
*/
export interface AutoRunTask {
autoRunSessionId: string;
sessionId: string;
agentType: string;
taskIndex: number;
taskContent?: string;
startTime: number;
duration: number;
success: boolean;
}
/**
* Session lifecycle event
*/
export interface SessionCreatedEvent {
sessionId: string;
agentType: string;
projectPath?: string;
createdAt: number;
isRemote?: boolean;
}
/**
* Aggregation result
*/
export interface StatsAggregation {
totalQueries: number;
totalDuration: number;
avgDuration: number;
byAgent: Record<string, { count: number; duration: number }>;
bySource: { user: number; auto: number };
byDay: Array<{ date: string; count: number; duration: number }>;
}
/**
* Creates the Stats API object for preload exposure
*/
export function createStatsApi() {
return {
// Record a query event (interactive conversation turn)
recordQuery: (event: QueryEvent): Promise<string> =>
ipcRenderer.invoke('stats:record-query', event),
// Start an Auto Run session (returns session ID)
startAutoRun: (session: AutoRunSession): Promise<string> =>
ipcRenderer.invoke('stats:start-autorun', session),
// End an Auto Run session (update duration and completed count)
endAutoRun: (id: string, duration: number, tasksCompleted: number): Promise<boolean> =>
ipcRenderer.invoke('stats:end-autorun', id, duration, tasksCompleted),
// Record an Auto Run task completion
recordAutoTask: (task: AutoRunTask): Promise<string> =>
ipcRenderer.invoke('stats:record-task', task),
// Get query events with time range and optional filters
getStats: (
range: 'day' | 'week' | 'month' | 'year' | 'all',
filters?: {
agentType?: string;
source?: 'user' | 'auto';
projectPath?: string;
sessionId?: string;
}
): Promise<
Array<{
id: string;
sessionId: string;
agentType: string;
source: 'user' | 'auto';
startTime: number;
duration: number;
projectPath?: string;
tabId?: string;
}>
> => ipcRenderer.invoke('stats:get-stats', range, filters),
// Get Auto Run sessions within a time range
getAutoRunSessions: (
range: 'day' | 'week' | 'month' | 'year' | 'all'
): Promise<
Array<{
id: string;
sessionId: string;
agentType: string;
documentPath?: string;
startTime: number;
duration: number;
tasksTotal?: number;
tasksCompleted?: number;
projectPath?: string;
}>
> => ipcRenderer.invoke('stats:get-autorun-sessions', range),
// Get tasks for a specific Auto Run session
getAutoRunTasks: (
autoRunSessionId: string
): Promise<
Array<{
id: string;
autoRunSessionId: string;
sessionId: string;
agentType: string;
taskIndex: number;
taskContent?: string;
startTime: number;
duration: number;
success: boolean;
}>
> => ipcRenderer.invoke('stats:get-autorun-tasks', autoRunSessionId),
// Get aggregated stats for dashboard display
getAggregation: (range: 'day' | 'week' | 'month' | 'year' | 'all'): Promise<StatsAggregation> =>
ipcRenderer.invoke('stats:get-aggregation', range),
// Export query events to CSV
exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all'): Promise<string> =>
ipcRenderer.invoke('stats:export-csv', range),
// Subscribe to stats updates (for real-time dashboard refresh)
onStatsUpdate: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('stats:updated', handler);
return () => ipcRenderer.removeListener('stats:updated', handler);
},
// Clear old stats data (older than specified number of days)
clearOldData: (
olderThanDays: number
): Promise<{
success: boolean;
deletedQueryEvents: number;
deletedAutoRunSessions: number;
deletedAutoRunTasks: number;
error?: string;
}> => ipcRenderer.invoke('stats:clear-old-data', olderThanDays),
// Get database size in bytes
getDatabaseSize: (): Promise<number> => ipcRenderer.invoke('stats:get-database-size'),
// Record session creation (for lifecycle tracking)
recordSessionCreated: (event: SessionCreatedEvent): Promise<string | null> =>
ipcRenderer.invoke('stats:record-session-created', event),
// Record session closure (for lifecycle tracking)
recordSessionClosed: (sessionId: string, closedAt: number): Promise<boolean> =>
ipcRenderer.invoke('stats:record-session-closed', sessionId, closedAt),
// Get session lifecycle events within a time range
getSessionLifecycle: (
range: 'day' | 'week' | 'month' | 'year' | 'all'
): Promise<
Array<{
id: string;
sessionId: string;
agentType: string;
projectPath?: string;
createdAt: number;
closedAt?: number;
duration?: number;
isRemote?: boolean;
}>
> => ipcRenderer.invoke('stats:get-session-lifecycle', range),
};
}
export type StatsApi = ReturnType<typeof createStatsApi>;

206
src/main/preload/system.ts Normal file
View File

@@ -0,0 +1,206 @@
/**
* Preload API for system operations
*
* Provides the window.maestro.dialog, fonts, shells, shell, tunnel, sync, devtools, power, updates, app namespaces
*/
import { ipcRenderer } from 'electron';
/**
* Shell information
*/
export interface ShellInfo {
id: string;
name: string;
available: boolean;
path?: string;
}
/**
* Update status from electron-updater
*/
export interface UpdateStatus {
status:
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'downloaded'
| 'error';
info?: { version: string };
progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number };
error?: string;
}
/**
* Creates the dialog API object for preload exposure
*/
export function createDialogApi() {
return {
selectFolder: () => ipcRenderer.invoke('dialog:selectFolder'),
saveFile: (options: {
defaultPath?: string;
filters?: Array<{ name: string; extensions: string[] }>;
title?: string;
}) => ipcRenderer.invoke('dialog:saveFile', options),
};
}
/**
* Creates the fonts API object for preload exposure
*/
export function createFontsApi() {
return {
detect: () => ipcRenderer.invoke('fonts:detect'),
};
}
/**
* Creates the shells API object for preload exposure
*/
export function createShellsApi() {
return {
detect: (): Promise<ShellInfo[]> => ipcRenderer.invoke('shells:detect'),
};
}
/**
* Creates the shell API object for preload exposure
*/
export function createShellApi() {
return {
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
trashItem: (itemPath: string) => ipcRenderer.invoke('shell:trashItem', itemPath),
};
}
/**
* Creates the tunnel API object for preload exposure
*/
export function createTunnelApi() {
return {
isCloudflaredInstalled: () => ipcRenderer.invoke('tunnel:isCloudflaredInstalled'),
start: () => ipcRenderer.invoke('tunnel:start'),
stop: () => ipcRenderer.invoke('tunnel:stop'),
getStatus: () => ipcRenderer.invoke('tunnel:getStatus'),
};
}
/**
* Creates the sync API object for preload exposure
*/
export function createSyncApi() {
return {
getDefaultPath: (): Promise<string> => ipcRenderer.invoke('sync:getDefaultPath'),
getSettings: (): Promise<{ customSyncPath?: string }> => ipcRenderer.invoke('sync:getSettings'),
getCurrentStoragePath: (): Promise<string> => ipcRenderer.invoke('sync:getCurrentStoragePath'),
selectSyncFolder: (): Promise<string | null> => ipcRenderer.invoke('sync:selectSyncFolder'),
setCustomPath: (
customPath: string | null
): Promise<{
success: boolean;
migrated?: number;
errors?: string[];
requiresRestart?: boolean;
error?: string;
}> => ipcRenderer.invoke('sync:setCustomPath', customPath),
};
}
/**
* Creates the devtools API object for preload exposure
*/
export function createDevtoolsApi() {
return {
open: () => ipcRenderer.invoke('devtools:open'),
close: () => ipcRenderer.invoke('devtools:close'),
toggle: () => ipcRenderer.invoke('devtools:toggle'),
};
}
/**
* Creates the power API object for preload exposure
*/
export function createPowerApi() {
return {
setEnabled: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke('power:setEnabled', enabled),
isEnabled: (): Promise<boolean> => ipcRenderer.invoke('power:isEnabled'),
getStatus: (): Promise<{
enabled: boolean;
blocking: boolean;
reasons: string[];
platform: 'darwin' | 'win32' | 'linux';
}> => ipcRenderer.invoke('power:getStatus'),
addReason: (reason: string): Promise<void> => ipcRenderer.invoke('power:addReason', reason),
removeReason: (reason: string): Promise<void> =>
ipcRenderer.invoke('power:removeReason', reason),
};
}
/**
* Creates the updates API object for preload exposure
*/
export function createUpdatesApi() {
return {
check: (
includePrerelease?: boolean
): Promise<{
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
versionsBehind: number;
releases: Array<{
tag_name: string;
name: string;
body: string;
html_url: string;
published_at: string;
}>;
releasesUrl: string;
error?: string;
}> => ipcRenderer.invoke('updates:check', includePrerelease),
download: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('updates:download'),
install: (): Promise<void> => ipcRenderer.invoke('updates:install'),
getStatus: (): Promise<UpdateStatus> => ipcRenderer.invoke('updates:getStatus'),
onStatus: (callback: (status: UpdateStatus) => void) => {
const handler = (_: any, status: UpdateStatus) => callback(status);
ipcRenderer.on('updates:status', handler);
return () => ipcRenderer.removeListener('updates:status', handler);
},
setAllowPrerelease: (allow: boolean): Promise<void> =>
ipcRenderer.invoke('updates:setAllowPrerelease', allow),
};
}
/**
* Creates the app lifecycle API object for preload exposure
*/
export function createAppApi() {
return {
onQuitConfirmationRequest: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('app:requestQuitConfirmation', handler);
return () => ipcRenderer.removeListener('app:requestQuitConfirmation', handler);
},
confirmQuit: () => {
ipcRenderer.send('app:quitConfirmed');
},
cancelQuit: () => {
ipcRenderer.send('app:quitCancelled');
},
};
}
export type DialogApi = ReturnType<typeof createDialogApi>;
export type FontsApi = ReturnType<typeof createFontsApi>;
export type ShellsApi = ReturnType<typeof createShellsApi>;
export type ShellApi = ReturnType<typeof createShellApi>;
export type TunnelApi = ReturnType<typeof createTunnelApi>;
export type SyncApi = ReturnType<typeof createSyncApi>;
export type DevtoolsApi = ReturnType<typeof createDevtoolsApi>;
export type PowerApi = ReturnType<typeof createPowerApi>;
export type UpdatesApi = ReturnType<typeof createUpdatesApi>;
export type AppApi = ReturnType<typeof createAppApi>;

103
src/main/preload/web.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Preload API for web interface operations
*
* Provides the window.maestro.web, webserver, and live namespaces for:
* - Broadcasting state to web clients
* - Web server management
* - Live session management
*/
import { ipcRenderer } from 'electron';
/**
* Auto Run state for broadcasting
*/
export interface AutoRunState {
isRunning: boolean;
totalTasks: number;
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
totalDocuments?: number;
currentDocumentIndex?: number;
totalTasksAcrossAllDocs?: number;
completedTasksAcrossAllDocs?: number;
}
/**
* AI Tab state for broadcasting
*/
export interface AiTabState {
id: string;
agentSessionId: string | null;
name: string | null;
starred: boolean;
inputValue: string;
usageStats?: any;
createdAt: number;
state: 'idle' | 'busy';
thinkingStartTime?: number | null;
}
/**
* Creates the web interface API object for preload exposure
*/
export function createWebApi() {
return {
// Broadcast user input to web clients (for keeping web interface in sync)
broadcastUserInput: (sessionId: string, command: string, inputMode: 'ai' | 'terminal') =>
ipcRenderer.invoke('web:broadcastUserInput', sessionId, command, inputMode),
// Broadcast AutoRun state to web clients (for showing task progress on mobile)
broadcastAutoRunState: (sessionId: string, state: AutoRunState | null) =>
ipcRenderer.invoke('web:broadcastAutoRunState', sessionId, state),
// Broadcast tab changes to web clients (for tab sync)
broadcastTabsChange: (sessionId: string, aiTabs: AiTabState[], activeTabId: string) =>
ipcRenderer.invoke('web:broadcastTabsChange', sessionId, aiTabs, activeTabId),
// Broadcast session state change to web clients (for real-time busy/idle updates)
broadcastSessionState: (
sessionId: string,
state: string,
additionalData?: {
name?: string;
toolType?: string;
inputMode?: string;
cwd?: string;
}
) => ipcRenderer.invoke('web:broadcastSessionState', sessionId, state, additionalData),
};
}
/**
* Creates the web server API object for preload exposure
*/
export function createWebserverApi() {
return {
getUrl: () => ipcRenderer.invoke('webserver:getUrl'),
getConnectedClients: () => ipcRenderer.invoke('webserver:getConnectedClients'),
};
}
/**
* Creates the live session API object for preload exposure
*/
export function createLiveApi() {
return {
toggle: (sessionId: string, agentSessionId?: string) =>
ipcRenderer.invoke('live:toggle', sessionId, agentSessionId),
getStatus: (sessionId: string) => ipcRenderer.invoke('live:getStatus', sessionId),
getDashboardUrl: () => ipcRenderer.invoke('live:getDashboardUrl'),
getLiveSessions: () => ipcRenderer.invoke('live:getLiveSessions'),
broadcastActiveSession: (sessionId: string) =>
ipcRenderer.invoke('live:broadcastActiveSession', sessionId),
disableAll: () => ipcRenderer.invoke('live:disableAll'),
startServer: () => ipcRenderer.invoke('live:startServer'),
stopServer: () => ipcRenderer.invoke('live:stopServer'),
};
}
export type WebApi = ReturnType<typeof createWebApi>;
export type WebserverApi = ReturnType<typeof createWebserverApi>;
export type LiveApi = ReturnType<typeof createLiveApi>;