From 5cb5606ba3cfa575365464afe2a9892c0aacd15f Mon Sep 17 00:00:00 2001 From: Davor Racic Date: Wed, 14 Jan 2026 23:25:35 +0100 Subject: [PATCH 1/3] fix(cli): replace inquirer with @clack/prompts for Windows compatibility (#1316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): replace inquirer with @clack/prompts for Windows compatibility - Add new prompts.js wrapper around @clack/prompts to fix Windows arrow key navigation issues (libuv #852) - Fix validation logic in github-copilot.js that always returned true - Add support for primitive choice values (string/number) in select/multiselect - Add 'when' property support for conditional questions in prompt() - Update all IDE installers to use new prompts module Co-Authored-By: Claude Opus 4.5 * fix(cli): address code review feedback for prompts migration - Move @clack/prompts from devDependencies to dependencies (critical) - Remove unused inquirer dependency - Fix potential crash in multiselect when initialValues is undefined - Add async validator detection with explicit error message - Extract validateCustomContentPathSync method in ui.js - Extract promptInstallLocation methods in claude-code.js and antigravity.js - Fix moduleId -> missing.id in installer.js remove flow - Update multiselect to support native clack API (options/initialValues) Co-Authored-By: Claude Opus 4.5 * chore: update comments to reference @clack/prompts instead of inquirer - Update bmad-cli.js comment about CLI prompts - Update config-collector.js JSDoc comments - Rename inquirer variable to choiceUtils in ui.js - Update JSDoc returns and calls documentation Co-Authored-By: Claude Opus 4.5 * fix(cli): add spacing between prompts and installation progress Co-Authored-By: Claude Opus 4.5 * fix(cli): add multiselect usage hints for inexperienced users Add inline navigation hints to all multiselect prompts showing (↑/↓ navigate, SPACE select, ENTER confirm) to help users unfamiliar with terminal multiselect controls. Also restore detailed warning when no tools are selected, explaining that SPACE must be pressed to select items. Co-Authored-By: Claude Opus 4.5 * feat(cli): restore IDE grouping using groupMultiselect Replace flat multiselect with native @clack/prompts groupMultiselect component to restore visual grouping of IDE/tool options: - "Previously Configured" - pre-selected IDEs from existing install - "Recommended Tools" - starred preferred options - "Additional Tools" - other available options This restores the grouped UX that was lost during the Inquirer.js to @clack/prompts migration. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- package-lock.json | 188 +---- package.json | 2 +- tools/cli/bmad-cli.js | 2 +- tools/cli/commands/install.js | 12 +- .../installers/lib/core/config-collector.js | 26 +- tools/cli/installers/lib/core/installer.js | 166 ++--- tools/cli/installers/lib/ide/antigravity.js | 91 +-- tools/cli/installers/lib/ide/claude-code.js | 91 +-- tools/cli/installers/lib/ide/codex.js | 51 +- .../cli/installers/lib/ide/github-copilot.js | 32 +- tools/cli/lib/prompts.js | 432 +++++++++++ tools/cli/lib/ui.js | 691 +++++++++--------- 12 files changed, 982 insertions(+), 802 deletions(-) create mode 100644 tools/cli/lib/prompts.js diff --git a/package-lock.json b/package-lock.json index ba812f09..18fd9a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "fs-extra": "^11.3.0", "glob": "^11.0.3", "ignore": "^7.0.5", - "inquirer": "^9.3.8", "js-yaml": "^4.1.0", "ora": "^5.4.1", "semver": "^7.6.3", @@ -34,6 +33,7 @@ "devDependencies": { "@astrojs/sitemap": "^3.6.0", "@astrojs/starlight": "^0.37.0", + "@clack/prompts": "^0.11.0", "@eslint/js": "^9.33.0", "archiver": "^7.0.1", "astro": "^5.16.0", @@ -244,7 +244,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -756,6 +755,29 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", + "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz", + "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "0.5.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1998,36 +2020,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -3641,9 +3633,8 @@ "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3983,7 +3974,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4031,6 +4021,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "license": "MIT", "dependencies": { "type-fest": "^0.21.3" @@ -4046,6 +4037,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -4290,7 +4282,6 @@ "integrity": "sha512-6mF/YrvwwRxLTu+aMEa5pwzKUNl5ZetWbTyZCs9Um0F12HUmxUiF5UHiZPy4rifzU3gtpM3xP2DfdmkNX9eZRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", @@ -5358,7 +5349,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5601,12 +5591,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5787,15 +5771,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6689,7 +6664,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8269,22 +8243,6 @@ "@babel/runtime": "^7.23.2" } }, - "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8420,43 +8378,6 @@ "dev": true, "license": "MIT" }, - "node_modules/inquirer": { - "version": "9.3.8", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", - "integrity": "sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==", - "license": "MIT", - "dependencies": { - "@inquirer/external-editor": "^1.0.2", - "@inquirer/figures": "^1.0.3", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -10304,7 +10225,6 @@ "integrity": "sha512-p3JTemJJbkiMjXEMiFwgm0v6ym5g8K+b2oDny+6xdl300tUKySxvilJQLSea48C6OaYNmO30kH9KxpiAg5bWJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "globby": "15.0.0", "js-yaml": "4.1.1", @@ -11576,15 +11496,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -12378,7 +12289,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12444,7 +12354,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13273,7 +13182,6 @@ "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13310,15 +13218,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13343,15 +13242,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -13372,12 +13262,6 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/sax": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", @@ -14251,6 +14135,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -14335,7 +14220,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-properties": { @@ -14837,7 +14722,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -15111,7 +14995,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15270,18 +15153,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", @@ -15303,7 +15174,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 0f8953f3..7cf03d1c 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ ] }, "dependencies": { + "@clack/prompts": "^0.11.0", "@kayvan/markdown-tree-parser": "^1.6.1", "boxen": "^5.1.2", "chalk": "^4.1.2", @@ -77,7 +78,6 @@ "fs-extra": "^11.3.0", "glob": "^11.0.3", "ignore": "^7.0.5", - "inquirer": "^9.3.8", "js-yaml": "^4.1.0", "ora": "^5.4.1", "semver": "^7.6.3", diff --git a/tools/cli/bmad-cli.js b/tools/cli/bmad-cli.js index 0cba5814..ad3aac34 100755 --- a/tools/cli/bmad-cli.js +++ b/tools/cli/bmad-cli.js @@ -3,7 +3,7 @@ const path = require('node:path'); const fs = require('node:fs'); // Fix for stdin issues when running through npm on Windows -// Ensures keyboard interaction works properly with inquirer prompts +// Ensures keyboard interaction works properly with CLI prompts if (process.stdin.isTTY) { try { process.stdin.resume(); diff --git a/tools/cli/commands/install.js b/tools/cli/commands/install.js index f71d5679..26b6425e 100644 --- a/tools/cli/commands/install.js +++ b/tools/cli/commands/install.js @@ -71,14 +71,10 @@ module.exports = { console.log(chalk.dim(' • ElevenLabs AI (150+ premium voices)')); console.log(chalk.dim(' • Piper TTS (50+ free voices)\n')); - const { default: inquirer } = await import('inquirer'); - await inquirer.prompt([ - { - type: 'input', - name: 'continue', - message: chalk.green('Press Enter to start AgentVibes installer...'), - }, - ]); + const prompts = require('../lib/prompts'); + await prompts.text({ + message: chalk.green('Press Enter to start AgentVibes installer...'), + }); console.log(''); diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index fb48b68d..ee486955 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -4,15 +4,7 @@ const yaml = require('yaml'); const chalk = require('chalk'); const { getProjectRoot, getModulePath } = require('../../../lib/project-root'); const { CLIUtils } = require('../../../lib/cli-utils'); - -// Lazy-load inquirer (ESM module) to avoid ERR_REQUIRE_ESM -let _inquirer = null; -async function getInquirer() { - if (!_inquirer) { - _inquirer = (await import('inquirer')).default; - } - return _inquirer; -} +const prompts = require('../../../lib/prompts'); class ConfigCollector { constructor() { @@ -183,7 +175,6 @@ class ConfigCollector { * @returns {boolean} True if new fields were prompted, false if all fields existed */ async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) { - const inquirer = await getInquirer(); this.currentProjectDir = projectDir; // Load existing config if not already loaded @@ -359,7 +350,7 @@ class ConfigCollector { // Only show header if we actually have questions CLIUtils.displayModuleConfigHeader(moduleName, moduleConfig.header, moduleConfig.subheader); console.log(); // Line break before questions - const promptedAnswers = await inquirer.prompt(questions); + const promptedAnswers = await prompts.prompt(questions); // Merge prompted answers with static answers Object.assign(allAnswers, promptedAnswers); @@ -502,7 +493,6 @@ class ConfigCollector { * @param {boolean} skipCompletion - Skip showing completion message (for early core collection) */ async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) { - const inquirer = await getInquirer(); this.currentProjectDir = projectDir; // Load existing config if needed and not already loaded if (!skipLoadExisting && !this.existingConfig) { @@ -597,7 +587,7 @@ class ConfigCollector { console.log(chalk.cyan('?') + ' ' + chalk.magenta(moduleDisplayName)); let customize = true; if (moduleName !== 'core') { - const customizeAnswer = await inquirer.prompt([ + const customizeAnswer = await prompts.prompt([ { type: 'confirm', name: 'customize', @@ -614,7 +604,7 @@ class ConfigCollector { if (questionsWithoutDefaults.length > 0) { console.log(chalk.dim(`\n Asking required questions for ${moduleName.toUpperCase()}...`)); - const promptedAnswers = await inquirer.prompt(questionsWithoutDefaults); + const promptedAnswers = await prompts.prompt(questionsWithoutDefaults); Object.assign(allAnswers, promptedAnswers); } @@ -628,7 +618,7 @@ class ConfigCollector { allAnswers[question.name] = question.default; } } else { - const promptedAnswers = await inquirer.prompt(questions); + const promptedAnswers = await prompts.prompt(questions); Object.assign(allAnswers, promptedAnswers); } } @@ -750,7 +740,7 @@ class ConfigCollector { console.log(chalk.cyan('?') + ' ' + chalk.magenta(moduleDisplayName)); // Ask user if they want to accept defaults or customize on the next line - const { customize } = await inquirer.prompt([ + const { customize } = await prompts.prompt([ { type: 'confirm', name: 'customize', @@ -845,7 +835,7 @@ class ConfigCollector { } /** - * Build an inquirer question from a config item + * Build a prompt question from a config item * @param {string} moduleName - Module name * @param {string} key - Config key * @param {Object} item - Config item definition @@ -1007,7 +997,7 @@ class ConfigCollector { message: message, }; - // Set default - if it's dynamic, use a function that inquirer will evaluate with current answers + // Set default - if it's dynamic, use a function that the prompt will evaluate with current answers // But if we have an existing value, always use that instead if (existingValue !== null && existingValue !== undefined && questionType !== 'list') { question.default = existingValue; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 8b7e05fd..97506847 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -16,6 +16,7 @@ const { CLIUtils } = require('../../../lib/cli-utils'); const { ManifestGenerator } = require('./manifest-generator'); const { IdeConfigManager } = require('./ide-config-manager'); const { CustomHandler } = require('../custom/handler'); +const prompts = require('../../../lib/prompts'); // BMAD installation folder name - this is constant and should never change const BMAD_FOLDER_NAME = '_bmad'; @@ -758,6 +759,9 @@ class Installer { config.skipIde = toolSelection.skipIde; const ideConfigurations = toolSelection.configurations; + // Add spacing after prompts before installation progress + console.log(''); + if (spinner.isSpinning) { spinner.text = 'Continuing installation...'; } else { @@ -2139,15 +2143,11 @@ class Installer { * Private: Prompt for update action */ async promptUpdateAction() { - const { default: inquirer } = await import('inquirer'); - return await inquirer.prompt([ - { - type: 'list', - name: 'action', - message: 'What would you like to do?', - choices: [{ name: 'Update existing installation', value: 'update' }], - }, - ]); + const action = await prompts.select({ + message: 'What would you like to do?', + choices: [{ name: 'Update existing installation', value: 'update' }], + }); + return { action }; } /** @@ -2156,8 +2156,6 @@ class Installer { * @param {Object} _legacyV4 - Legacy V4 detection result (unused in simplified version) */ async handleLegacyV4Migration(_projectDir, _legacyV4) { - const { default: inquirer } = await import('inquirer'); - console.log(''); console.log(chalk.yellow.bold('⚠️ Legacy BMAD v4 detected')); console.log(chalk.yellow('─'.repeat(80))); @@ -2172,26 +2170,22 @@ class Installer { console.log(chalk.dim('If your v4 installation set up rules or commands, you should remove those as well.')); console.log(''); - const { proceed } = await inquirer.prompt([ - { - type: 'list', - name: 'proceed', - message: 'What would you like to do?', - choices: [ - { - name: 'Exit and clean up manually (recommended)', - value: 'exit', - short: 'Exit installation', - }, - { - name: 'Continue with installation anyway', - value: 'continue', - short: 'Continue', - }, - ], - default: 'exit', - }, - ]); + const proceed = await prompts.select({ + message: 'What would you like to do?', + choices: [ + { + name: 'Exit and clean up manually (recommended)', + value: 'exit', + hint: 'Exit installation', + }, + { + name: 'Continue with installation anyway', + value: 'continue', + hint: 'Continue', + }, + ], + default: 'exit', + }); if (proceed === 'exit') { console.log(''); @@ -2437,7 +2431,6 @@ class Installer { console.log(chalk.yellow(`\n⚠️ Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`)); - const { default: inquirer } = await import('inquirer'); let keptCount = 0; let updatedCount = 0; let removedCount = 0; @@ -2451,12 +2444,12 @@ class Installer { { name: 'Keep installed (will not be processed)', value: 'keep', - short: 'Keep', + hint: 'Keep', }, { name: 'Specify new source location', value: 'update', - short: 'Update', + hint: 'Update', }, ]; @@ -2465,47 +2458,40 @@ class Installer { choices.push({ name: '⚠️ REMOVE module completely (destructive!)', value: 'remove', - short: 'Remove', + hint: 'Remove', }); } - const { action } = await inquirer.prompt([ - { - type: 'list', - name: 'action', - message: `How would you like to handle "${missing.name}"?`, - choices, - }, - ]); + const action = await prompts.select({ + message: `How would you like to handle "${missing.name}"?`, + choices, + }); switch (action) { case 'update': { - const { newSourcePath } = await inquirer.prompt([ - { - type: 'input', - name: 'newSourcePath', - message: 'Enter the new path to the custom module:', - default: missing.sourcePath, - validate: async (input) => { - if (!input || input.trim() === '') { - return 'Please enter a path'; - } - const expandedPath = path.resolve(input.trim()); - if (!(await fs.pathExists(expandedPath))) { - return 'Path does not exist'; - } - // Check if it looks like a valid module - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - const agentsPath = path.join(expandedPath, 'agents'); - const workflowsPath = path.join(expandedPath, 'workflows'); + // Use sync validation because @clack/prompts doesn't support async validate + const newSourcePath = await prompts.text({ + message: 'Enter the new path to the custom module:', + default: missing.sourcePath, + validate: (input) => { + if (!input || input.trim() === '') { + return 'Please enter a path'; + } + const expandedPath = path.resolve(input.trim()); + if (!fs.pathExistsSync(expandedPath)) { + return 'Path does not exist'; + } + // Check if it looks like a valid module + const moduleYamlPath = path.join(expandedPath, 'module.yaml'); + const agentsPath = path.join(expandedPath, 'agents'); + const workflowsPath = path.join(expandedPath, 'workflows'); - if (!(await fs.pathExists(moduleYamlPath)) && !(await fs.pathExists(agentsPath)) && !(await fs.pathExists(workflowsPath))) { - return 'Path does not appear to contain a valid custom module'; - } - return true; - }, + if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) { + return 'Path does not appear to contain a valid custom module'; + } + return; // clack expects undefined for valid input }, - ]); + }); // Update the source in manifest const resolvedPath = path.resolve(newSourcePath.trim()); @@ -2531,46 +2517,38 @@ class Installer { console.log(chalk.red.bold(`\n⚠️ WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!`)); console.log(chalk.red(` Module location: ${path.join(bmadDir, missing.id)}`)); - const { confirm } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirm', - message: chalk.red.bold('Are you absolutely sure you want to delete this module?'), - default: false, - }, - ]); + const confirmDelete = await prompts.confirm({ + message: chalk.red.bold('Are you absolutely sure you want to delete this module?'), + default: false, + }); - if (confirm) { - const { typedConfirm } = await inquirer.prompt([ - { - type: 'input', - name: 'typedConfirm', - message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'), - validate: (input) => { - if (input !== 'DELETE') { - return chalk.red('You must type "DELETE" exactly to proceed'); - } - return true; - }, + if (confirmDelete) { + const typedConfirm = await prompts.text({ + message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'), + validate: (input) => { + if (input !== 'DELETE') { + return chalk.red('You must type "DELETE" exactly to proceed'); + } + return; // clack expects undefined for valid input }, - ]); + }); if (typedConfirm === 'DELETE') { // Remove the module from filesystem and manifest - const modulePath = path.join(bmadDir, moduleId); + const modulePath = path.join(bmadDir, missing.id); if (await fs.pathExists(modulePath)) { const fsExtra = require('fs-extra'); await fsExtra.remove(modulePath); console.log(chalk.yellow(` ✓ Deleted module directory: ${path.relative(projectRoot, modulePath)}`)); } - await this.manifest.removeModule(bmadDir, moduleId); - await this.manifest.removeCustomModule(bmadDir, moduleId); + await this.manifest.removeModule(bmadDir, missing.id); + await this.manifest.removeCustomModule(bmadDir, missing.id); console.log(chalk.yellow(` ✓ Removed from manifest`)); // Also remove from installedModules list - if (installedModules && installedModules.includes(moduleId)) { - const index = installedModules.indexOf(moduleId); + if (installedModules && installedModules.includes(missing.id)) { + const index = installedModules.indexOf(missing.id); if (index !== -1) { installedModules.splice(index, 1); } @@ -2591,7 +2569,7 @@ class Installer { } case 'keep': { keptCount++; - keptModulesWithoutSources.push(moduleId); + keptModulesWithoutSources.push(missing.id); console.log(chalk.dim(` Module will be kept as-is`)); break; diff --git a/tools/cli/installers/lib/ide/antigravity.js b/tools/cli/installers/lib/ide/antigravity.js index c896d62d..57071cdc 100644 --- a/tools/cli/installers/lib/ide/antigravity.js +++ b/tools/cli/installers/lib/ide/antigravity.js @@ -13,6 +13,7 @@ const { resolveSubagentFiles, } = require('./shared/module-injections'); const { getAgentsFromBmad, getAgentsFromDir } = require('./shared/bmad-artifacts'); +const prompts = require('../../../lib/prompts'); /** * Google Antigravity IDE setup handler @@ -26,6 +27,21 @@ class AntigravitySetup extends BaseIdeSetup { this.workflowsDir = 'workflows'; } + /** + * Prompt for subagent installation location + * @returns {Promise} Selected location ('project' or 'user') + */ + async _promptInstallLocation() { + return prompts.select({ + message: 'Where would you like to install Antigravity subagents?', + choices: [ + { name: 'Project level (.agent/agents/)', value: 'project' }, + { name: 'User level (~/.agent/agents/)', value: 'user' }, + ], + default: 'project', + }); + } + /** * Collect configuration choices before installation * @param {Object} options - Configuration options @@ -57,21 +73,7 @@ class AntigravitySetup extends BaseIdeSetup { config.subagentChoices = await this.promptSubagentInstallation(injectionConfig.subagents); if (config.subagentChoices.install !== 'none') { - // Ask for installation location - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Antigravity subagents?', - choices: [ - { name: 'Project level (.agent/agents/)', value: 'project' }, - { name: 'User level (~/.agent/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - config.installLocation = locationAnswer.location; + config.installLocation = await this._promptInstallLocation(); } } } catch (error) { @@ -297,20 +299,7 @@ class AntigravitySetup extends BaseIdeSetup { choices = await this.promptSubagentInstallation(config.subagents); if (choices.install !== 'none') { - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Antigravity subagents?', - choices: [ - { name: 'Project level (.agent/agents/)', value: 'project' }, - { name: 'User level (~/.agent/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - location = locationAnswer.location; + location = await this._promptInstallLocation(); } } @@ -334,22 +323,16 @@ class AntigravitySetup extends BaseIdeSetup { * Prompt user for subagent installation preferences */ async promptSubagentInstallation(subagentConfig) { - const { default: inquirer } = await import('inquirer'); - // First ask if they want to install subagents - const { install } = await inquirer.prompt([ - { - type: 'list', - name: 'install', - message: 'Would you like to install Antigravity subagents for enhanced functionality?', - choices: [ - { name: 'Yes, install all subagents', value: 'all' }, - { name: 'Yes, let me choose specific subagents', value: 'selective' }, - { name: 'No, skip subagent installation', value: 'none' }, - ], - default: 'all', - }, - ]); + const install = await prompts.select({ + message: 'Would you like to install Antigravity subagents for enhanced functionality?', + choices: [ + { name: 'Yes, install all subagents', value: 'all' }, + { name: 'Yes, let me choose specific subagents', value: 'selective' }, + { name: 'No, skip subagent installation', value: 'none' }, + ], + default: 'all', + }); if (install === 'selective') { // Show list of available subagents with descriptions @@ -361,18 +344,14 @@ class AntigravitySetup extends BaseIdeSetup { 'document-reviewer.md': 'Document quality review', }; - const { selected } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selected', - message: 'Select subagents to install:', - choices: subagentConfig.files.map((file) => ({ - name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, - value: file, - checked: true, - })), - }, - ]); + const selected = await prompts.multiselect({ + message: `Select subagents to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices: subagentConfig.files.map((file) => ({ + name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, + value: file, + checked: true, + })), + }); return { install: 'selective', selected }; } diff --git a/tools/cli/installers/lib/ide/claude-code.js b/tools/cli/installers/lib/ide/claude-code.js index f2a33221..02c65b40 100644 --- a/tools/cli/installers/lib/ide/claude-code.js +++ b/tools/cli/installers/lib/ide/claude-code.js @@ -13,6 +13,7 @@ const { resolveSubagentFiles, } = require('./shared/module-injections'); const { getAgentsFromBmad, getAgentsFromDir } = require('./shared/bmad-artifacts'); +const prompts = require('../../../lib/prompts'); /** * Claude Code IDE setup handler @@ -25,6 +26,21 @@ class ClaudeCodeSetup extends BaseIdeSetup { this.agentsDir = 'agents'; } + /** + * Prompt for subagent installation location + * @returns {Promise} Selected location ('project' or 'user') + */ + async promptInstallLocation() { + return prompts.select({ + message: 'Where would you like to install Claude Code subagents?', + choices: [ + { name: 'Project level (.claude/agents/)', value: 'project' }, + { name: 'User level (~/.claude/agents/)', value: 'user' }, + ], + default: 'project', + }); + } + /** * Collect configuration choices before installation * @param {Object} options - Configuration options @@ -56,21 +72,7 @@ class ClaudeCodeSetup extends BaseIdeSetup { config.subagentChoices = await this.promptSubagentInstallation(injectionConfig.subagents); if (config.subagentChoices.install !== 'none') { - // Ask for installation location - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Claude Code subagents?', - choices: [ - { name: 'Project level (.claude/agents/)', value: 'project' }, - { name: 'User level (~/.claude/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - config.installLocation = locationAnswer.location; + config.installLocation = await this.promptInstallLocation(); } } } catch (error) { @@ -305,20 +307,7 @@ class ClaudeCodeSetup extends BaseIdeSetup { choices = await this.promptSubagentInstallation(config.subagents); if (choices.install !== 'none') { - const { default: inquirer } = await import('inquirer'); - const locationAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Claude Code subagents?', - choices: [ - { name: 'Project level (.claude/agents/)', value: 'project' }, - { name: 'User level (~/.claude/agents/)', value: 'user' }, - ], - default: 'project', - }, - ]); - location = locationAnswer.location; + location = await this.promptInstallLocation(); } } @@ -342,22 +331,16 @@ class ClaudeCodeSetup extends BaseIdeSetup { * Prompt user for subagent installation preferences */ async promptSubagentInstallation(subagentConfig) { - const { default: inquirer } = await import('inquirer'); - // First ask if they want to install subagents - const { install } = await inquirer.prompt([ - { - type: 'list', - name: 'install', - message: 'Would you like to install Claude Code subagents for enhanced functionality?', - choices: [ - { name: 'Yes, install all subagents', value: 'all' }, - { name: 'Yes, let me choose specific subagents', value: 'selective' }, - { name: 'No, skip subagent installation', value: 'none' }, - ], - default: 'all', - }, - ]); + const install = await prompts.select({ + message: 'Would you like to install Claude Code subagents for enhanced functionality?', + choices: [ + { name: 'Yes, install all subagents', value: 'all' }, + { name: 'Yes, let me choose specific subagents', value: 'selective' }, + { name: 'No, skip subagent installation', value: 'none' }, + ], + default: 'all', + }); if (install === 'selective') { // Show list of available subagents with descriptions @@ -369,18 +352,14 @@ class ClaudeCodeSetup extends BaseIdeSetup { 'document-reviewer.md': 'Document quality review', }; - const { selected } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selected', - message: 'Select subagents to install:', - choices: subagentConfig.files.map((file) => ({ - name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, - value: file, - checked: true, - })), - }, - ]); + const selected = await prompts.multiselect({ + message: `Select subagents to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + options: subagentConfig.files.map((file) => ({ + label: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`, + value: file, + })), + initialValues: subagentConfig.files, + }); return { install: 'selective', selected }; } diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 3ce9d910..e037a779 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -6,6 +6,7 @@ const { BaseIdeSetup } = require('./_base-ide'); const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator'); const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { getTasksFromBmad } = require('./shared/bmad-artifacts'); +const prompts = require('../../../lib/prompts'); /** * Codex setup handler (CLI mode) @@ -21,32 +22,24 @@ class CodexSetup extends BaseIdeSetup { * @returns {Object} Collected configuration */ async collectConfiguration(options = {}) { - const { default: inquirer } = await import('inquirer'); - let confirmed = false; let installLocation = 'global'; while (!confirmed) { - const { location } = await inquirer.prompt([ - { - type: 'list', - name: 'location', - message: 'Where would you like to install Codex CLI prompts?', - choices: [ - { - name: 'Global - Simple for single project ' + '(~/.codex/prompts, but references THIS project only)', - value: 'global', - }, - { - name: `Project-specific - Recommended for real work (requires CODEX_HOME=${path.sep}.codex)`, - value: 'project', - }, - ], - default: 'global', - }, - ]); - - installLocation = location; + installLocation = await prompts.select({ + message: 'Where would you like to install Codex CLI prompts?', + choices: [ + { + name: 'Global - Simple for single project ' + '(~/.codex/prompts, but references THIS project only)', + value: 'global', + }, + { + name: `Project-specific - Recommended for real work (requires CODEX_HOME=${path.sep}.codex)`, + value: 'project', + }, + ], + default: 'global', + }); // Display detailed instructions for the chosen option console.log(''); @@ -57,16 +50,10 @@ class CodexSetup extends BaseIdeSetup { } // Confirm the choice - const { proceed } = await inquirer.prompt([ - { - type: 'confirm', - name: 'proceed', - message: 'Proceed with this installation option?', - default: true, - }, - ]); - - confirmed = proceed; + confirmed = await prompts.confirm({ + message: 'Proceed with this installation option?', + default: true, + }); if (!confirmed) { console.log(chalk.yellow("\n Let's choose a different installation option.\n")); diff --git a/tools/cli/installers/lib/ide/github-copilot.js b/tools/cli/installers/lib/ide/github-copilot.js index b9dd5f98..c500a284 100644 --- a/tools/cli/installers/lib/ide/github-copilot.js +++ b/tools/cli/installers/lib/ide/github-copilot.js @@ -2,6 +2,7 @@ const path = require('node:path'); const { BaseIdeSetup } = require('./_base-ide'); const chalk = require('chalk'); const { AgentCommandGenerator } = require('./shared/agent-command-generator'); +const prompts = require('../../../lib/prompts'); /** * GitHub Copilot setup handler @@ -21,29 +22,23 @@ class GitHubCopilotSetup extends BaseIdeSetup { * @returns {Object} Collected configuration */ async collectConfiguration(options = {}) { - const { default: inquirer } = await import('inquirer'); const config = {}; console.log('\n' + chalk.blue(' 🔧 VS Code Settings Configuration')); console.log(chalk.dim(' GitHub Copilot works best with specific settings\n')); - const response = await inquirer.prompt([ - { - type: 'list', - name: 'configChoice', - message: 'How would you like to configure VS Code settings?', - choices: [ - { name: 'Use recommended defaults (fastest)', value: 'defaults' }, - { name: 'Configure each setting manually', value: 'manual' }, - { name: 'Skip settings configuration', value: 'skip' }, - ], - default: 'defaults', - }, - ]); - config.vsCodeConfig = response.configChoice; + config.vsCodeConfig = await prompts.select({ + message: 'How would you like to configure VS Code settings?', + choices: [ + { name: 'Use recommended defaults (fastest)', value: 'defaults' }, + { name: 'Configure each setting manually', value: 'manual' }, + { name: 'Skip settings configuration', value: 'skip' }, + ], + default: 'defaults', + }); - if (response.configChoice === 'manual') { - config.manualSettings = await inquirer.prompt([ + if (config.vsCodeConfig === 'manual') { + config.manualSettings = await prompts.prompt([ { type: 'input', name: 'maxRequests', @@ -52,7 +47,8 @@ class GitHubCopilotSetup extends BaseIdeSetup { validate: (input) => { const num = parseInt(input, 10); if (isNaN(num)) return 'Enter a valid number 1-50'; - return (num >= 1 && num <= 50) || 'Enter 1-50'; + if (num < 1 || num > 50) return 'Enter a number between 1-50'; + return true; }, }, { diff --git a/tools/cli/lib/prompts.js b/tools/cli/lib/prompts.js new file mode 100644 index 00000000..96b80ba1 --- /dev/null +++ b/tools/cli/lib/prompts.js @@ -0,0 +1,432 @@ +/** + * @clack/prompts wrapper for BMAD CLI + * + * This module provides a unified interface for CLI prompts using @clack/prompts. + * It replaces Inquirer.js to fix Windows arrow key navigation issues (libuv #852). + * + * @module prompts + */ + +let _clack = null; + +/** + * Lazy-load @clack/prompts (ESM module) + * @returns {Promise} The clack prompts module + */ +async function getClack() { + if (!_clack) { + _clack = await import('@clack/prompts'); + } + return _clack; +} + +/** + * Handle user cancellation gracefully + * @param {any} value - The value to check + * @param {string} [message='Operation cancelled'] - Message to display + * @returns {boolean} True if cancelled + */ +async function handleCancel(value, message = 'Operation cancelled') { + const clack = await getClack(); + if (clack.isCancel(value)) { + clack.cancel(message); + process.exit(0); + } + return false; +} + +/** + * Display intro message + * @param {string} message - The intro message + */ +async function intro(message) { + const clack = await getClack(); + clack.intro(message); +} + +/** + * Display outro message + * @param {string} message - The outro message + */ +async function outro(message) { + const clack = await getClack(); + clack.outro(message); +} + +/** + * Display a note/info box + * @param {string} message - The note content + * @param {string} [title] - Optional title + */ +async function note(message, title) { + const clack = await getClack(); + clack.note(message, title); +} + +/** + * Display a spinner for async operations + * @returns {Object} Spinner controller with start, stop, message methods + */ +async function spinner() { + const clack = await getClack(); + return clack.spinner(); +} + +/** + * Single-select prompt (replaces Inquirer 'list' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Array} options.choices - Array of choices [{name, value, hint?}] + * @param {any} [options.default] - Default selected value + * @returns {Promise} Selected value + */ +async function select(options) { + const clack = await getClack(); + + // Convert Inquirer-style choices to clack format + // Handle both object choices {name, value, hint} and primitive choices (string/number) + const clackOptions = options.choices + .filter((c) => c.type !== 'separator') // Skip separators for now + .map((choice) => { + if (typeof choice === 'string' || typeof choice === 'number') { + return { value: choice, label: String(choice) }; + } + return { + value: choice.value === undefined ? choice.name : choice.value, + label: choice.name || choice.label || String(choice.value), + hint: choice.hint || choice.description, + }; + }); + + // Find initial value + let initialValue; + if (options.default !== undefined) { + initialValue = options.default; + } + + const result = await clack.select({ + message: options.message, + options: clackOptions, + initialValue, + }); + + await handleCancel(result); + return result; +} + +/** + * Multi-select prompt (replaces Inquirer 'checkbox' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Array} options.choices - Array of choices [{name, value, checked?, hint?}] + * @param {boolean} [options.required=false] - Whether at least one must be selected + * @returns {Promise} Array of selected values + */ +async function multiselect(options) { + const clack = await getClack(); + + // Support both clack-native (options) and Inquirer-style (choices) APIs + let clackOptions; + let initialValues; + + if (options.options) { + // Native clack format: options with label/value + clackOptions = options.options; + initialValues = options.initialValues || []; + } else { + // Convert Inquirer-style choices to clack format + // Handle both object choices {name, value, hint} and primitive choices (string/number) + clackOptions = options.choices + .filter((c) => c.type !== 'separator') // Skip separators + .map((choice) => { + if (typeof choice === 'string' || typeof choice === 'number') { + return { value: choice, label: String(choice) }; + } + return { + value: choice.value === undefined ? choice.name : choice.value, + label: choice.name || choice.label || String(choice.value), + hint: choice.hint || choice.description, + }; + }); + + // Find initial values (pre-checked items) + initialValues = options.choices + .filter((c) => c.checked && c.type !== 'separator') + .map((c) => (c.value === undefined ? c.name : c.value)); + } + + const result = await clack.multiselect({ + message: options.message, + options: clackOptions, + initialValues: initialValues.length > 0 ? initialValues : undefined, + required: options.required || false, + }); + + await handleCancel(result); + return result; +} + +/** + * Grouped multi-select prompt for categorized options + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Object} options.options - Object mapping group names to arrays of choices + * @param {Array} [options.initialValues] - Array of initially selected values + * @param {boolean} [options.required=false] - Whether at least one must be selected + * @param {boolean} [options.selectableGroups=false] - Whether groups can be selected as a whole + * @returns {Promise} Array of selected values + */ +async function groupMultiselect(options) { + const clack = await getClack(); + + const result = await clack.groupMultiselect({ + message: options.message, + options: options.options, + initialValues: options.initialValues, + required: options.required || false, + }); + + await handleCancel(result); + return result; +} + +/** + * Confirm prompt (replaces Inquirer 'confirm' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {boolean} [options.default=true] - Default value + * @returns {Promise} User's answer + */ +async function confirm(options) { + const clack = await getClack(); + + const result = await clack.confirm({ + message: options.message, + initialValue: options.default === undefined ? true : options.default, + }); + + await handleCancel(result); + return result; +} + +/** + * Text input prompt (replaces Inquirer 'input' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {string} [options.default] - Default value + * @param {string} [options.placeholder] - Placeholder text (defaults to options.default if not provided) + * @param {Function} [options.validate] - Validation function + * @returns {Promise} User's input + */ +async function text(options) { + const clack = await getClack(); + + // Use default as placeholder if placeholder not explicitly provided + // This shows the default value as grayed-out hint text + const placeholder = options.placeholder === undefined ? options.default : options.placeholder; + + const result = await clack.text({ + message: options.message, + defaultValue: options.default, + placeholder: typeof placeholder === 'string' ? placeholder : undefined, + validate: options.validate, + }); + + await handleCancel(result); + return result; +} + +/** + * Password input prompt (replaces Inquirer 'password' type) + * @param {Object} options - Prompt options + * @param {string} options.message - The question to ask + * @param {Function} [options.validate] - Validation function + * @returns {Promise} User's input + */ +async function password(options) { + const clack = await getClack(); + + const result = await clack.password({ + message: options.message, + validate: options.validate, + }); + + await handleCancel(result); + return result; +} + +/** + * Group multiple prompts together + * @param {Object} prompts - Object of prompt functions + * @param {Object} [options] - Group options + * @returns {Promise} Object with all answers + */ +async function group(prompts, options = {}) { + const clack = await getClack(); + + const result = await clack.group(prompts, { + onCancel: () => { + clack.cancel('Operation cancelled'); + process.exit(0); + }, + ...options, + }); + + return result; +} + +/** + * Run tasks with spinner feedback + * @param {Array} tasks - Array of task objects [{title, task, enabled?}] + * @returns {Promise} + */ +async function tasks(taskList) { + const clack = await getClack(); + await clack.tasks(taskList); +} + +/** + * Log messages with styling + */ +const log = { + async info(message) { + const clack = await getClack(); + clack.log.info(message); + }, + async success(message) { + const clack = await getClack(); + clack.log.success(message); + }, + async warn(message) { + const clack = await getClack(); + clack.log.warn(message); + }, + async error(message) { + const clack = await getClack(); + clack.log.error(message); + }, + async message(message) { + const clack = await getClack(); + clack.log.message(message); + }, + async step(message) { + const clack = await getClack(); + clack.log.step(message); + }, +}; + +/** + * Execute an array of Inquirer-style questions using @clack/prompts + * This provides compatibility with dynamic question arrays + * @param {Array} questions - Array of Inquirer-style question objects + * @returns {Promise} Object with answers keyed by question name + */ +async function prompt(questions) { + const answers = {}; + + for (const question of questions) { + const { type, name, message, choices, default: defaultValue, validate, when } = question; + + // Handle conditional questions via 'when' property + if (when !== undefined) { + const shouldAsk = typeof when === 'function' ? await when(answers) : when; + if (!shouldAsk) continue; + } + + let answer; + + switch (type) { + case 'input': { + // Note: @clack/prompts doesn't support async validation, so validate must be sync + answer = await text({ + message, + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + validate: validate + ? (val) => { + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + } + return result === true ? undefined : result; + } + : undefined, + }); + break; + } + + case 'confirm': { + answer = await confirm({ + message, + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + }); + break; + } + + case 'list': { + answer = await select({ + message, + choices: choices || [], + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + }); + break; + } + + case 'checkbox': { + answer = await multiselect({ + message, + choices: choices || [], + required: false, + }); + break; + } + + case 'password': { + // Note: @clack/prompts doesn't support async validation, so validate must be sync + answer = await password({ + message, + validate: validate + ? (val) => { + const result = validate(val, answers); + if (result instanceof Promise) { + throw new TypeError('Async validation is not supported by @clack/prompts. Please use synchronous validation.'); + } + return result === true ? undefined : result; + } + : undefined, + }); + break; + } + + default: { + // Default to text input for unknown types + answer = await text({ + message, + default: typeof defaultValue === 'function' ? defaultValue(answers) : defaultValue, + }); + } + } + + answers[name] = answer; + } + + return answers; +} + +module.exports = { + getClack, + handleCancel, + intro, + outro, + note, + spinner, + select, + multiselect, + groupMultiselect, + confirm, + text, + password, + group, + tasks, + log, + prompt, +}; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 85f31ce1..a78cfec9 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -4,16 +4,21 @@ const os = require('node:os'); const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); const { CustomHandler } = require('../installers/lib/custom/handler'); +const prompts = require('./prompts'); -// Lazy-load inquirer (ESM module) to avoid ERR_REQUIRE_ESM -let _inquirer = null; -async function getInquirer() { - if (!_inquirer) { - _inquirer = (await import('inquirer')).default; +// Separator class for visual grouping in select/multiselect prompts +// Note: @clack/prompts doesn't support separators natively, they are filtered out +class Separator { + constructor(text = '────────') { + this.line = text; + this.name = text; } - return _inquirer; + type = 'separator'; } +// Separator for choice lists (compatible interface) +const choiceUtils = { Separator }; + /** * UI utilities for the installer */ @@ -23,7 +28,6 @@ class UI { * @returns {Object} Installation configuration */ async promptInstall() { - const inquirer = await getInquirer(); CLIUtils.displayLogo(); // Display version-specific start message from install-messages.yaml @@ -113,26 +117,20 @@ class UI { console.log(chalk.yellow('─'.repeat(80))); console.log(''); - const { proceed } = await inquirer.prompt([ - { - type: 'list', - name: 'proceed', - message: 'What would you like to do?', - choices: [ - { - name: 'Cancel and do a fresh install (recommended)', - value: 'cancel', - short: 'Cancel installation', - }, - { - name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)', - value: 'proceed', - short: 'Proceed with update', - }, - ], - default: 'cancel', - }, - ]); + const proceed = await prompts.select({ + message: 'What would you like to do?', + choices: [ + { + name: 'Cancel and do a fresh install (recommended)', + value: 'cancel', + }, + { + name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)', + value: 'proceed', + }, + ], + default: 'cancel', + }); if (proceed === 'cancel') { console.log(''); @@ -188,14 +186,10 @@ class UI { // If Claude Code was selected, ask about TTS if (claudeCodeSelected) { - const { enableTts } = await inquirer.prompt([ - { - type: 'confirm', - name: 'enableTts', - message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', - default: false, - }, - ]); + const enableTts = await prompts.confirm({ + message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', + default: false, + }); if (enableTts) { agentVibesConfig = { enabled: true, alreadyInstalled: false }; @@ -250,18 +244,11 @@ class UI { // Common actions choices.push({ name: 'Modify BMAD Installation', value: 'update' }); - const promptResult = await inquirer.prompt([ - { - type: 'list', - name: 'actionType', - message: 'What would you like to do?', - choices: choices, - default: choices[0].value, // Use the first option as default - }, - ]); - - // Extract actionType from prompt result - actionType = promptResult.actionType; + actionType = await prompts.select({ + message: 'What would you like to do?', + choices: choices, + default: choices[0].value, + }); // Handle quick update separately if (actionType === 'quick-update') { @@ -290,14 +277,10 @@ class UI { const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); console.log(chalk.dim(` Found existing modules: ${[...installedModuleIds].join(', ')}`)); - const { changeModuleSelection } = await inquirer.prompt([ - { - type: 'confirm', - name: 'changeModuleSelection', - message: 'Modify official module selection (BMad Method, BMad Builder, Creative Innovation Suite)?', - default: false, - }, - ]); + const changeModuleSelection = await prompts.confirm({ + message: 'Modify official module selection (BMad Method, BMad Builder, Creative Innovation Suite)?', + default: false, + }); let selectedModules = []; if (changeModuleSelection) { @@ -310,14 +293,10 @@ class UI { // After module selection, ask about custom modules console.log(''); - const { changeCustomModules } = await inquirer.prompt([ - { - type: 'confirm', - name: 'changeCustomModules', - message: 'Modify custom module selection (add, update, or remove custom modules/agents/workflows)?', - default: false, - }, - ]); + const changeCustomModules = await prompts.confirm({ + message: 'Modify custom module selection (add, update, or remove custom modules/agents/workflows)?', + default: false, + }); let customModuleResult = { selectedCustomModules: [], customContentConfig: { hasCustomContent: false } }; if (changeCustomModules) { @@ -352,15 +331,10 @@ class UI { let enableTts = false; if (hasClaudeCode) { - const { enableTts: enable } = await inquirer.prompt([ - { - type: 'confirm', - name: 'enableTts', - message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', - default: false, - }, - ]); - enableTts = enable; + enableTts = await prompts.confirm({ + message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', + default: false, + }); } // Core config with existing defaults (ask after TTS) @@ -385,14 +359,10 @@ class UI { const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); // Ask about official modules for new installations - const { wantsOfficialModules } = await inquirer.prompt([ - { - type: 'confirm', - name: 'wantsOfficialModules', - message: 'Will you be installing any official BMad modules (BMad Method, BMad Builder, Creative Innovation Suite)?', - default: true, - }, - ]); + const wantsOfficialModules = await prompts.confirm({ + message: 'Will you be installing any official BMad modules (BMad Method, BMad Builder, Creative Innovation Suite)?', + default: true, + }); let selectedOfficialModules = []; if (wantsOfficialModules) { @@ -401,14 +371,10 @@ class UI { } // Ask about custom content - const { wantsCustomContent } = await inquirer.prompt([ - { - type: 'confirm', - name: 'wantsCustomContent', - message: 'Would you like to install a local custom module (this includes custom agents and workflows also)?', - default: false, - }, - ]); + const wantsCustomContent = await prompts.confirm({ + message: 'Would you like to install a local custom module (this includes custom agents and workflows also)?', + default: false, + }); if (wantsCustomContent) { customContentConfig = await this.promptCustomContentSource(); @@ -459,7 +425,6 @@ class UI { * @returns {Object} Tool configuration */ async promptToolSelection(projectDir, selectedModules) { - const inquirer = await getInquirer(); // Check for existing configured IDEs - use findBmadDir to detect custom folder names const { Detector } = require('../installers/lib/core/detector'); const { Installer } = require('../installers/lib/core/installer'); @@ -477,13 +442,14 @@ class UI { const preferredIdes = ideManager.getPreferredIdes(); const otherIdes = ideManager.getOtherIdes(); - // Build IDE choices array with separators - const ideChoices = []; + // Build grouped options object for groupMultiselect + const groupedOptions = {}; const processedIdes = new Set(); + const initialValues = []; // First, add previously configured IDEs at the top, marked with ✅ if (configuredIdes.length > 0) { - ideChoices.push(new inquirer.Separator('── Previously Configured ──')); + const configuredGroup = []; for (const ideValue of configuredIdes) { // Skip empty or invalid IDE values if (!ideValue || typeof ideValue !== 'string') { @@ -496,81 +462,71 @@ class UI { const ide = preferredIde || otherIde; if (ide) { - ideChoices.push({ - name: `${ide.name} ✅`, + configuredGroup.push({ + label: `${ide.name} ✅`, value: ide.value, - checked: true, // Previously configured IDEs are checked by default }); processedIdes.add(ide.value); + initialValues.push(ide.value); // Pre-select configured IDEs } else { // Warn about unrecognized IDE (but don't fail) console.log(chalk.yellow(`⚠️ Previously configured IDE '${ideValue}' is no longer available`)); } } + if (configuredGroup.length > 0) { + groupedOptions['Previously Configured'] = configuredGroup; + } } // Add preferred tools (excluding already processed) const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value)); if (remainingPreferred.length > 0) { - ideChoices.push(new inquirer.Separator('── Recommended Tools ──')); - for (const ide of remainingPreferred) { - ideChoices.push({ - name: `${ide.name} ⭐`, - value: ide.value, - checked: false, - }); + groupedOptions['Recommended Tools'] = remainingPreferred.map((ide) => { processedIdes.add(ide.value); - } + return { + label: `${ide.name} ⭐`, + value: ide.value, + }; + }); } // Add other tools (excluding already processed) const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value)); if (remainingOther.length > 0) { - ideChoices.push(new inquirer.Separator('── Additional Tools ──')); - for (const ide of remainingOther) { - ideChoices.push({ - name: ide.name, - value: ide.value, - checked: false, - }); - } + groupedOptions['Additional Tools'] = remainingOther.map((ide) => ({ + label: ide.name, + value: ide.value, + })); } - let answers; + let selectedIdes = []; let userConfirmedNoTools = false; // Loop until user selects at least one tool OR explicitly confirms no tools while (!userConfirmedNoTools) { - answers = await inquirer.prompt([ - { - type: 'checkbox', - name: 'ides', - message: 'Select tools to configure:', - choices: ideChoices, - pageSize: 30, - }, - ]); + selectedIdes = await prompts.groupMultiselect({ + message: `Select tools to configure ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + options: groupedOptions, + initialValues: initialValues.length > 0 ? initialValues : undefined, + required: false, + }); // If tools were selected, we're done - if (answers.ides && answers.ides.length > 0) { + if (selectedIdes && selectedIdes.length > 0) { break; } // Warn that no tools were selected - users often miss the spacebar requirement console.log(); console.log(chalk.red.bold('⚠️ WARNING: No tools were selected!')); - console.log(chalk.red(' You must press SPACEBAR to select items, then ENTER to confirm.')); + console.log(chalk.red(' You must press SPACE to select items, then ENTER to confirm.')); console.log(chalk.red(' Simply highlighting an item does NOT select it.')); console.log(); - const { goBack } = await inquirer.prompt([ - { - type: 'confirm', - name: 'goBack', - message: chalk.yellow('Would you like to go back and select at least one tool?'), - default: true, - }, - ]); + const goBack = await prompts.confirm({ + message: chalk.yellow('Would you like to go back and select at least one tool?'), + default: true, + }); if (goBack) { // Re-display a message before looping back @@ -582,8 +538,8 @@ class UI { } return { - ides: answers.ides || [], - skipIde: !answers.ides || answers.ides.length === 0, + ides: selectedIdes || [], + skipIde: !selectedIdes || selectedIdes.length === 0, }; } @@ -592,23 +548,17 @@ class UI { * @returns {Object} Update configuration */ async promptUpdate() { - const inquirer = await getInquirer(); - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'backupFirst', - message: 'Create backup before updating?', - default: true, - }, - { - type: 'confirm', - name: 'preserveCustomizations', - message: 'Preserve local customizations?', - default: true, - }, - ]); + const backupFirst = await prompts.confirm({ + message: 'Create backup before updating?', + default: true, + }); - return answers; + const preserveCustomizations = await prompts.confirm({ + message: 'Preserve local customizations?', + default: true, + }); + + return { backupFirst, preserveCustomizations }; } /** @@ -617,27 +567,17 @@ class UI { * @returns {Array} Selected modules */ async promptModules(modules) { - const inquirer = await getInquirer(); const choices = modules.map((mod) => ({ name: `${mod.name} - ${mod.description}`, value: mod.id, checked: false, })); - const { selectedModules } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'selectedModules', - message: 'Select modules to add:', - choices, - validate: (answer) => { - if (answer.length === 0) { - return 'You must choose at least one module.'; - } - return true; - }, - }, - ]); + const selectedModules = await prompts.multiselect({ + message: `Select modules to add ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices, + required: true, + }); return selectedModules; } @@ -649,17 +589,10 @@ class UI { * @returns {boolean} User confirmation */ async confirm(message, defaultValue = false) { - const inquirer = await getInquirer(); - const { confirmed } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirmed', - message, - default: defaultValue, - }, - ]); - - return confirmed; + return await prompts.confirm({ + message, + default: defaultValue, + }); } /** @@ -753,10 +686,9 @@ class UI { * Get module choices for selection * @param {Set} installedModuleIds - Currently installed module IDs * @param {Object} customContentConfig - Custom content configuration - * @returns {Array} Module choices for inquirer + * @returns {Array} Module choices for prompt */ async getModuleChoices(installedModuleIds, customContentConfig = null) { - const inquirer = await getInquirer(); const moduleChoices = []; const isNewInstallation = installedModuleIds.size === 0; @@ -811,9 +743,9 @@ class UI { if (allCustomModules.length > 0) { // Add separator for custom content, all custom modules, and official content separator moduleChoices.push( - new inquirer.Separator('── Custom Content ──'), + new choiceUtils.Separator('── Custom Content ──'), ...allCustomModules, - new inquirer.Separator('── Official Content ──'), + new choiceUtils.Separator('── Official Content ──'), ); } @@ -837,44 +769,43 @@ class UI { * @returns {Array} Selected module IDs */ async selectModules(moduleChoices, defaultSelections = []) { - const inquirer = await getInquirer(); - const moduleAnswer = await inquirer.prompt([ - { - type: 'checkbox', - name: 'modules', - message: 'Select modules to install:', - choices: moduleChoices, - default: defaultSelections, - }, - ]); + // Mark choices as checked based on defaultSelections + const choicesWithDefaults = moduleChoices.map((choice) => ({ + ...choice, + checked: defaultSelections.includes(choice.value), + })); - const selected = moduleAnswer.modules || []; + const selected = await prompts.multiselect({ + message: `Select modules to install ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices: choicesWithDefaults, + required: false, + }); - return selected; + return selected || []; } /** * Prompt for directory selection - * @returns {Object} Directory answer from inquirer + * @returns {Object} Directory answer from prompt */ async promptForDirectory() { - const inquirer = await getInquirer(); - return await inquirer.prompt([ - { - type: 'input', - name: 'directory', - message: `Installation directory:`, - default: process.cwd(), - validate: async (input) => this.validateDirectory(input), - filter: (input) => { - // If empty, use the default - if (!input || input.trim() === '') { - return process.cwd(); - } - return this.expandUserPath(input); - }, - }, - ]); + // Use sync validation because @clack/prompts doesn't support async validate + const directory = await prompts.text({ + message: 'Installation directory:', + default: process.cwd(), + placeholder: process.cwd(), + validate: (input) => this.validateDirectorySync(input), + }); + + // Apply filter logic + let filteredDir = directory; + if (!filteredDir || filteredDir.trim() === '') { + filteredDir = process.cwd(); + } else { + filteredDir = this.expandUserPath(filteredDir); + } + + return { directory: filteredDir }; } /** @@ -915,45 +846,92 @@ class UI { * @returns {boolean} Whether user confirmed */ async confirmDirectory(directory) { - const inquirer = await getInquirer(); const dirExists = await fs.pathExists(directory); if (dirExists) { - const confirmAnswer = await inquirer.prompt([ - { - type: 'confirm', - name: 'proceed', - message: `Install to this directory?`, - default: true, - }, - ]); + const proceed = await prompts.confirm({ + message: 'Install to this directory?', + default: true, + }); - if (!confirmAnswer.proceed) { + if (!proceed) { console.log(chalk.yellow("\nLet's try again with a different path.\n")); } - return confirmAnswer.proceed; + return proceed; } else { // Ask for confirmation to create the directory - const createConfirm = await inquirer.prompt([ - { - type: 'confirm', - name: 'create', - message: `The directory '${directory}' doesn't exist. Would you like to create it?`, - default: false, - }, - ]); + const create = await prompts.confirm({ + message: `The directory '${directory}' doesn't exist. Would you like to create it?`, + default: false, + }); - if (!createConfirm.create) { + if (!create) { console.log(chalk.yellow("\nLet's try again with a different path.\n")); } - return createConfirm.create; + return create; } } /** - * Validate directory path for installation + * Validate directory path for installation (sync version for clack prompts) + * @param {string} input - User input path + * @returns {string|undefined} Error message or undefined if valid + */ + validateDirectorySync(input) { + // Allow empty input to use the default + if (!input || input.trim() === '') { + return; // Empty means use default, undefined = valid for clack + } + + let expandedPath; + try { + expandedPath = this.expandUserPath(input.trim()); + } catch (error) { + return error.message; + } + + // Check if the path exists + const pathExists = fs.pathExistsSync(expandedPath); + + if (!pathExists) { + // Find the first existing parent directory + const existingParent = this.findExistingParentSync(expandedPath); + + if (!existingParent) { + return 'Cannot create directory: no existing parent directory found'; + } + + // Check if the existing parent is writable + try { + fs.accessSync(existingParent, fs.constants.W_OK); + // Path doesn't exist but can be created - will prompt for confirmation later + return; + } catch { + // Provide a detailed error message explaining both issues + return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`; + } + } + + // If it exists, validate it's a directory and writable + const stat = fs.statSync(expandedPath); + if (!stat.isDirectory()) { + return `Path exists but is not a directory: ${expandedPath}`; + } + + // Check write permissions + try { + fs.accessSync(expandedPath, fs.constants.W_OK); + } catch { + return `Directory is not writable: ${expandedPath}`; + } + + return; + } + + /** + * Validate directory path for installation (async version) * @param {string} input - User input path * @returns {string|true} Error message or true if valid */ @@ -1009,7 +987,28 @@ class UI { } /** - * Find the first existing parent directory + * Find the first existing parent directory (sync version) + * @param {string} targetPath - The path to check + * @returns {string|null} The first existing parent directory, or null if none found + */ + findExistingParentSync(targetPath) { + let currentPath = path.resolve(targetPath); + + // Walk up the directory tree until we find an existing directory + while (currentPath !== path.dirname(currentPath)) { + // Stop at root + const parent = path.dirname(currentPath); + if (fs.pathExistsSync(parent)) { + return parent; + } + currentPath = parent; + } + + return null; // No existing parent found (shouldn't happen in practice) + } + + /** + * Find the first existing parent directory (async version) * @param {string} targetPath - The path to check * @returns {string|null} The first existing parent directory, or null if none found */ @@ -1071,7 +1070,7 @@ class UI { * @sideeffects None - pure user input collection, no files written * @edgecases Shows warning if user enables TTS but AgentVibes not detected * @calledby promptInstall() during installation flow, after core config, before IDE selection - * @calls checkAgentVibesInstalled(), inquirer.prompt(), chalk.green/yellow/dim() + * @calls checkAgentVibesInstalled(), prompts.select(), chalk.green/yellow/dim() * * AI NOTE: This prompt is strategically positioned in installation flow: * - AFTER core config (user_name, etc) @@ -1102,7 +1101,6 @@ class UI { * - GitHub Issue: paulpreibisch/AgentVibes#36 */ async promptAgentVibes(projectDir) { - const inquirer = await getInquirer(); CLIUtils.displaySection('🎤 Voice Features', 'Enable TTS for multi-agent conversations'); // Check if AgentVibes is already installed @@ -1114,23 +1112,19 @@ class UI { console.log(chalk.dim(' AgentVibes not detected')); } - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'enableTts', - message: 'Enable Agents to Speak Out loud (powered by Agent Vibes? Claude Code only currently)', - default: false, // Default to yes - recommended for best experience - }, - ]); + const enableTts = await prompts.confirm({ + message: 'Enable Agents to Speak Out loud (powered by Agent Vibes? Claude Code only currently)', + default: false, + }); - if (answers.enableTts && !agentVibesInstalled) { + if (enableTts && !agentVibesInstalled) { console.log(chalk.yellow('\n ⚠️ AgentVibes not installed')); console.log(chalk.dim(' Install AgentVibes separately to enable TTS:')); console.log(chalk.dim(' https://github.com/paulpreibisch/AgentVibes\n')); } return { - enabled: answers.enableTts, + enabled: enableTts, alreadyInstalled: agentVibesInstalled, }; } @@ -1248,30 +1242,75 @@ class UI { return existingInstall.ides || []; } + /** + * Validate custom content path synchronously + * @param {string} input - User input path + * @returns {string|undefined} Error message or undefined if valid + */ + validateCustomContentPathSync(input) { + // Allow empty input to cancel + if (!input || input.trim() === '') { + return; // Allow empty to exit + } + + try { + // Expand the path + const expandedPath = this.expandUserPath(input.trim()); + + // Check if path exists + if (!fs.pathExistsSync(expandedPath)) { + return 'Path does not exist'; + } + + // Check if it's a directory + const stat = fs.statSync(expandedPath); + if (!stat.isDirectory()) { + return 'Path must be a directory'; + } + + // Check for module.yaml in the root + const moduleYamlPath = path.join(expandedPath, 'module.yaml'); + if (!fs.pathExistsSync(moduleYamlPath)) { + return 'Directory must contain a module.yaml file in the root'; + } + + // Try to parse the module.yaml to get the module ID + try { + const yaml = require('yaml'); + const content = fs.readFileSync(moduleYamlPath, 'utf8'); + const moduleData = yaml.parse(content); + if (!moduleData.code) { + return 'module.yaml must contain a "code" field for the module ID'; + } + } catch (error) { + return 'Invalid module.yaml file: ' + error.message; + } + + return; // Valid + } catch (error) { + return 'Error validating path: ' + error.message; + } + } + /** * Prompt user for custom content source location * @returns {Object} Custom content configuration */ async promptCustomContentSource() { - const inquirer = await getInquirer(); const customContentConfig = { hasCustomContent: true, sources: [] }; // Keep asking for more sources until user is done while (true) { // First ask if user wants to add another module or continue if (customContentConfig.sources.length > 0) { - const { action } = await inquirer.prompt([ - { - type: 'list', - name: 'action', - message: 'Would you like to:', - choices: [ - { name: 'Add another custom module', value: 'add' }, - { name: 'Continue with installation', value: 'continue' }, - ], - default: 'continue', - }, - ]); + const action = await prompts.select({ + message: 'Would you like to:', + choices: [ + { name: 'Add another custom module', value: 'add' }, + { name: 'Continue with installation', value: 'continue' }, + ], + default: 'continue', + }); if (action === 'continue') { break; @@ -1282,57 +1321,11 @@ class UI { let isValid = false; while (!isValid) { - const { path: inputPath } = await inquirer.prompt([ - { - type: 'input', - name: 'path', - message: 'Enter the path to your custom content folder (or press Enter to cancel):', - validate: async (input) => { - // Allow empty input to cancel - if (!input || input.trim() === '') { - return true; // Allow empty to exit - } - - try { - // Expand the path - const expandedPath = this.expandUserPath(input.trim()); - - // Check if path exists - if (!(await fs.pathExists(expandedPath))) { - return 'Path does not exist'; - } - - // Check if it's a directory - const stat = await fs.stat(expandedPath); - if (!stat.isDirectory()) { - return 'Path must be a directory'; - } - - // Check for module.yaml in the root - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - if (!(await fs.pathExists(moduleYamlPath))) { - return 'Directory must contain a module.yaml file in the root'; - } - - // Try to parse the module.yaml to get the module ID - try { - const yaml = require('yaml'); - const content = await fs.readFile(moduleYamlPath, 'utf8'); - const moduleData = yaml.parse(content); - if (!moduleData.code) { - return 'module.yaml must contain a "code" field for the module ID'; - } - } catch (error) { - return 'Invalid module.yaml file: ' + error.message; - } - - return true; - } catch (error) { - return 'Error validating path: ' + error.message; - } - }, - }, - ]); + // Use sync validation because @clack/prompts doesn't support async validate + const inputPath = await prompts.text({ + message: 'Enter the path to your custom content folder (or press Enter to cancel):', + validate: (input) => this.validateCustomContentPathSync(input), + }); // If user pressed Enter without typing anything, exit the loop if (!inputPath || inputPath.trim() === '') { @@ -1364,14 +1357,10 @@ class UI { } // Ask if user wants to add these to the installation - const { shouldInstall } = await inquirer.prompt([ - { - type: 'confirm', - name: 'shouldInstall', - message: `Install ${customContentConfig.sources.length} custom module(s) now?`, - default: true, - }, - ]); + const shouldInstall = await prompts.confirm({ + message: `Install ${customContentConfig.sources.length} custom module(s) now?`, + default: true, + }); if (shouldInstall) { customContentConfig.selected = true; @@ -1391,7 +1380,6 @@ class UI { * @returns {Object} Result with selected custom modules and custom content config */ async handleCustomModulesInModifyFlow(directory, selectedModules) { - const inquirer = await getInquirer(); // Get existing installation to find custom modules const { existingInstall } = await this.getExistingInstallation(directory); @@ -1451,16 +1439,11 @@ class UI { choices.push({ name: 'Add new custom modules', value: 'add' }, { name: 'Cancel (no custom modules)', value: 'cancel' }); } - const { customAction } = await inquirer.prompt([ - { - type: 'list', - name: 'customAction', - message: - cachedCustomModules.length > 0 ? 'What would you like to do with custom modules?' : 'Would you like to add custom modules?', - choices: choices, - default: cachedCustomModules.length > 0 ? 'keep' : 'add', - }, - ]); + const customAction = await prompts.select({ + message: cachedCustomModules.length > 0 ? 'What would you like to do with custom modules?' : 'Would you like to add custom modules?', + choices: choices, + default: cachedCustomModules.length > 0 ? 'keep' : 'add', + }); switch (customAction) { case 'keep': { @@ -1472,21 +1455,18 @@ class UI { case 'select': { // Let user choose which to keep - const choices = cachedCustomModules.map((m) => ({ + const selectChoices = cachedCustomModules.map((m) => ({ name: `${m.name} ${chalk.gray(`(${m.id})`)}`, value: m.id, + checked: m.checked, })); - const { keepModules } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'keepModules', - message: 'Select custom modules to keep:', - choices: choices, - default: cachedCustomModules.filter((m) => m.checked).map((m) => m.id), - }, - ]); - result.selectedCustomModules = keepModules; + const keepModules = await prompts.multiselect({ + message: `Select custom modules to keep ${chalk.dim('(↑/↓ navigate, SPACE select, ENTER confirm)')}:`, + choices: selectChoices, + required: false, + }); + result.selectedCustomModules = keepModules || []; break; } @@ -1586,7 +1566,6 @@ class UI { * @returns {Promise} True if user wants to proceed, false if they cancel */ async showOldAlphaVersionWarning(installedVersion, currentVersion, bmadFolderName) { - const inquirer = await getInquirer(); const versionInfo = this.checkAlphaVersionAge(installedVersion, currentVersion); // Also warn if version is unknown or can't be parsed (legacy/unsupported) @@ -1627,26 +1606,20 @@ class UI { console.log(chalk.yellow('─'.repeat(80))); console.log(''); - const { proceed } = await inquirer.prompt([ - { - type: 'list', - name: 'proceed', - message: 'What would you like to do?', - choices: [ - { - name: 'Proceed with update anyway (may have issues)', - value: 'proceed', - short: 'Proceed with update', - }, - { - name: 'Cancel (recommended - do a fresh install instead)', - value: 'cancel', - short: 'Cancel installation', - }, - ], - default: 'cancel', - }, - ]); + const proceed = await prompts.select({ + message: 'What would you like to do?', + choices: [ + { + name: 'Proceed with update anyway (may have issues)', + value: 'proceed', + }, + { + name: 'Cancel (recommended - do a fresh install instead)', + value: 'cancel', + }, + ], + default: 'cancel', + }); if (proceed === 'cancel') { console.log(''); From 993d02b8b3bd51c142fe9dd8de5be3a9a28b70d8 Mon Sep 17 00:00:00 2001 From: VJSai Date: Thu, 15 Jan 2026 03:57:52 +0530 Subject: [PATCH 2/3] Enhance security policy documentation (#1312) Expanded the security policy to include supported versions, reporting guidelines, response timelines, security scope, and best practices for users. Co-authored-by: Alex Verkhovsky --- SECURITY.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..2c565ed1 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,85 @@ +# Security Policy + +## Supported Versions + +We release security patches for the following versions: + +| Version | Supported | +| ------- | ------------------ | +| Latest | :white_check_mark: | +| < Latest | :x: | + +We recommend always using the latest version of BMad Method to ensure you have the most recent security updates. + +## Reporting a Vulnerability + +We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly. + +### How to Report + +**Do NOT report security vulnerabilities through public GitHub issues.** + +Instead, please report them via one of these methods: + +1. **GitHub Security Advisories** (Preferred): Use [GitHub's private vulnerability reporting](https://github.com/bmad-code-org/BMAD-METHOD/security/advisories/new) to submit a confidential report. + +2. **Discord**: Contact a maintainer directly via DM on our [Discord server](https://discord.gg/gk8jAdXWmj). + +### What to Include + +Please include as much of the following information as possible: + +- Type of vulnerability (e.g., prompt injection, path traversal, etc.) +- Full paths of source file(s) related to the vulnerability +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if available) +- Impact assessment of the vulnerability + +### Response Timeline + +- **Initial Response**: Within 48 hours of receiving your report +- **Status Update**: Within 7 days with our assessment +- **Resolution Target**: Critical issues within 30 days; other issues within 90 days + +### What to Expect + +1. We will acknowledge receipt of your report +2. We will investigate and validate the vulnerability +3. We will work on a fix and coordinate disclosure timing with you +4. We will credit you in the security advisory (unless you prefer to remain anonymous) + +## Security Scope + +### In Scope + +- Vulnerabilities in BMad Method core framework code +- Security issues in agent definitions or workflows that could lead to unintended behavior +- Path traversal or file system access issues +- Prompt injection vulnerabilities that bypass intended agent behavior +- Supply chain vulnerabilities in dependencies + +### Out of Scope + +- Security issues in user-created custom agents or modules +- Vulnerabilities in third-party AI providers (Claude, GPT, etc.) +- Issues that require physical access to a user's machine +- Social engineering attacks +- Denial of service attacks that don't exploit a specific vulnerability + +## Security Best Practices for Users + +When using BMad Method: + +1. **Review Agent Outputs**: Always review AI-generated code before executing it +2. **Limit File Access**: Configure your AI IDE to limit file system access where possible +3. **Keep Updated**: Regularly update to the latest version +4. **Validate Dependencies**: Review any dependencies added by generated code +5. **Environment Isolation**: Consider running AI-assisted development in isolated environments + +## Acknowledgments + +We appreciate the security research community's efforts in helping keep BMad Method secure. Contributors who report valid security issues will be acknowledged in our security advisories. + +--- + +Thank you for helping keep BMad Method and our community safe. From 1d8df63ac573b9aa9b3cef0187b250cd386e7b60 Mon Sep 17 00:00:00 2001 From: sjennings Date: Wed, 14 Jan 2026 20:53:40 -0600 Subject: [PATCH 3/3] feat(bmgd): Add E2E testing methodology and scaffold workflow (#1322) * feat(bmgd): Add E2E testing methodology and scaffold workflow - Add comprehensive e2e-testing.md knowledge fragment - Add e2e-scaffold workflow for infrastructure generation - Update qa-index.csv with e2e-testing fragment reference - Update game-qa.agent.yaml with ES trigger - Update test-design and automate instructions with E2E guidance - Update unity-testing.md with E2E section reference * fix(bmgd): improve E2E testing infrastructure robustness - Add WaitForValueApprox overloads for float/double comparisons - Fix assembly definition to use precompiledReferences for test runners - Fix CaptureOnFailure to yield before screenshot capture (main thread) - Add error handling to test file cleanup with try/catch - Fix ClickButton to use FindObjectsByType and check scene.isLoaded - Add engine-specific output paths (Unity/Unreal/Godot) to workflow - Fix knowledge_fragments paths to use correct relative paths * feat(bmgd): add E2E testing support for Godot and Unreal Godot: - Add C# testing with xUnit/NSubstitute alongside GDScript GUT - Add E2E infrastructure: GameE2ETestFixture, ScenarioBuilder, InputSimulator, AsyncAssert (all GDScript) - Add example E2E tests and quick checklist Unreal: - Add E2E infrastructure extending AFunctionalTest - Add GameE2ETestBase, ScenarioBuilder, InputSimulator classes - Add AsyncTestHelpers with latent commands and macros - Add example E2E tests for combat and turn cycle - Add CLI commands for running E2E tests --------- Co-authored-by: Scott Jennings Co-authored-by: Brian --- src/modules/bmgd/agents/game-qa.agent.yaml | 6 + .../bmgd/gametest/knowledge/e2e-testing.md | 1013 +++++++++++++++ .../bmgd/gametest/knowledge/godot-testing.md | 499 ++++++++ .../bmgd/gametest/knowledge/unity-testing.md | 14 + .../bmgd/gametest/knowledge/unreal-testing.md | 1126 ++++++++++++++++ src/modules/bmgd/gametest/qa-index.csv | 3 +- .../gametest/automate/instructions.md | 81 ++ .../gametest/e2e-scaffold/checklist.md | 95 ++ .../gametest/e2e-scaffold/instructions.md | 1137 +++++++++++++++++ .../gametest/e2e-scaffold/workflow.yaml | 145 +++ .../gametest/test-design/instructions.md | 57 +- 11 files changed, 4169 insertions(+), 7 deletions(-) create mode 100644 src/modules/bmgd/gametest/knowledge/e2e-testing.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md create mode 100644 src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml diff --git a/src/modules/bmgd/agents/game-qa.agent.yaml b/src/modules/bmgd/agents/game-qa.agent.yaml index a1eddbc6..973d521c 100644 --- a/src/modules/bmgd/agents/game-qa.agent.yaml +++ b/src/modules/bmgd/agents/game-qa.agent.yaml @@ -22,6 +22,8 @@ agent: critical_actions: - "Consult {project-root}/_bmad/bmgd/gametest/qa-index.csv to select knowledge fragments under knowledge/ and load only the files needed for the current task" + - "For E2E testing requests, always load knowledge/e2e-testing.md first" + - "When scaffolding tests, distinguish between unit, integration, and E2E test needs" - "Load the referenced fragment(s) from {project-root}/_bmad/bmgd/gametest/knowledge/ before giving recommendations" - "Cross-check recommendations with the current official Unity Test Framework, Unreal Automation, or Godot GUT documentation" - "Find if this exists, if it does, always treat it as the bible I plan and execute against: `**/project-context.md`" @@ -43,6 +45,10 @@ agent: workflow: "{project-root}/_bmad/bmgd/workflows/gametest/automate/workflow.yaml" description: "[TA] Generate automated game tests" + - trigger: ES or fuzzy match on e2e-scaffold + workflow: "{project-root}/_bmad/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml" + description: "[ES] Scaffold E2E testing infrastructure" + - trigger: PP or fuzzy match on playtest-plan workflow: "{project-root}/_bmad/bmgd/workflows/gametest/playtest-plan/workflow.yaml" description: "[PP] Create structured playtesting plan" diff --git a/src/modules/bmgd/gametest/knowledge/e2e-testing.md b/src/modules/bmgd/gametest/knowledge/e2e-testing.md new file mode 100644 index 00000000..8f35bcd7 --- /dev/null +++ b/src/modules/bmgd/gametest/knowledge/e2e-testing.md @@ -0,0 +1,1013 @@ +# End-to-End Testing for Games + +## Overview + +E2E tests validate complete gameplay flows from the player's perspective — the full orchestra, not individual instruments. Unlike integration tests that verify system interactions, E2E tests verify *player journeys* work correctly from start to finish. + +This is the difference between "does the damage calculator work with the inventory system?" (integration) and "can a player actually complete a combat encounter from selection to resolution?" (E2E). + +## E2E vs Integration vs Unit + +| Aspect | Unit | Integration | E2E | +|--------|------|-------------|-----| +| Scope | Single class | System interaction | Complete flow | +| Speed | < 10ms | < 1s | 1-30s | +| Stability | Very stable | Stable | Requires care | +| Example | DamageCalc math | Combat + Inventory | Full combat encounter | +| Dependencies | None/mocked | Some real | All real | +| Catches | Logic bugs | Wiring bugs | Journey bugs | + +## The E2E Testing Pyramid Addition + +``` + /\ + / \ Manual Playtesting + /----\ (Fun, Feel, Experience) + / \ + /--------\ E2E Tests + / \ (Player Journeys) + /------------\ + / \ Integration Tests + /----------------\ (System Interactions) + / \ Unit Tests + /____________________\ (Pure Logic) +``` + +E2E tests sit between integration tests and manual playtesting. They automate what *can* be automated about player experience while acknowledging that "is this fun?" still requires human judgment. + +## E2E Infrastructure Requirements + +Before writing E2E tests, scaffold supporting infrastructure. Without this foundation, E2E tests become brittle, flaky nightmares that erode trust faster than they build confidence. + +### 1. Test Fixture Base Class + +Provides scene loading, cleanup, and common utilities. Every E2E test inherits from this. + +**Unity Example:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +public abstract class GameE2ETestFixture +{ + protected virtual string SceneName => "GameScene"; + protected GameStateManager GameState { get; private set; } + protected InputSimulator Input { get; private set; } + protected ScenarioBuilder Scenario { get; private set; } + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + // Load the game scene + yield return SceneManager.LoadSceneAsync(SceneName); + yield return null; // Wait one frame for scene initialization + + // Get core references + GameState = Object.FindFirstObjectByType(); + Assert.IsNotNull(GameState, $"GameStateManager not found in {SceneName}"); + + // Initialize test utilities + Input = new InputSimulator(); + Scenario = new ScenarioBuilder(GameState); + + // Wait for game to be ready + yield return WaitForGameReady(); + } + + [UnityTearDown] + public IEnumerator BaseTearDown() + { + // Clean up any test-spawned objects + yield return CleanupTestObjects(); + + // Reset input state + Input?.Reset(); + } + + protected IEnumerator WaitForGameReady(float timeout = 10f) + { + yield return AsyncAssert.WaitUntil( + () => GameState != null && GameState.IsReady, + "Game ready state", + timeout); + } + + protected virtual IEnumerator CleanupTestObjects() + { + // Override in derived classes for game-specific cleanup + yield return null; + } +} +``` + +**Unreal Example:** + +```cpp +// GameE2ETestBase.h +UCLASS() +class AGameE2ETestBase : public AFunctionalTest +{ + GENERATED_BODY() + +protected: + UPROPERTY() + UGameStateManager* GameState; + + UPROPERTY() + UInputSimulator* InputSim; + + UPROPERTY() + UScenarioBuilder* Scenario; + + virtual void PrepareTest() override; + virtual void StartTest() override; + virtual void CleanUp() override; + + void WaitForGameReady(float Timeout = 10.f); +}; +``` + +**Godot Example:** + +```gdscript +extends GutTest +class_name GameE2ETestFixture + +var game_state: GameStateManager +var input_sim: InputSimulator +var scenario: ScenarioBuilder +var _scene_instance: Node + +func before_each(): + # Load game scene + var scene = load("res://scenes/game.tscn") + _scene_instance = scene.instantiate() + add_child(_scene_instance) + + # Get references + game_state = _scene_instance.get_node("GameStateManager") + input_sim = InputSimulator.new() + scenario = ScenarioBuilder.new(game_state) + + # Wait for ready + await wait_for_game_ready() + +func after_each(): + if _scene_instance: + _scene_instance.queue_free() + input_sim = null + scenario = null + +func wait_for_game_ready(timeout: float = 10.0): + var elapsed = 0.0 + while not game_state.is_ready and elapsed < timeout: + await get_tree().process_frame + elapsed += get_process_delta_time() + assert_true(game_state.is_ready, "Game should be ready") +``` + +### 2. Scenario Builder (Fluent API) + +Configure game state for test scenarios without manual setup. This is the secret sauce — it lets you express test preconditions in domain language. + +**Unity Example:** + +```csharp +public class ScenarioBuilder +{ + private readonly GameStateManager _gameState; + private readonly List> _setupActions = new(); + + public ScenarioBuilder(GameStateManager gameState) + { + _gameState = gameState; + } + + // Domain-specific setup methods + public ScenarioBuilder WithUnit(Faction faction, Hex position, int movementPoints = 6) + { + _setupActions.Add(() => SpawnUnit(faction, position, movementPoints)); + return this; + } + + public ScenarioBuilder WithTerrain(Hex position, TerrainType terrain) + { + _setupActions.Add(() => SetTerrain(position, terrain)); + return this; + } + + public ScenarioBuilder OnTurn(int turnNumber) + { + _setupActions.Add(() => SetTurn(turnNumber)); + return this; + } + + public ScenarioBuilder OnPhase(TurnPhase phase) + { + _setupActions.Add(() => SetPhase(phase)); + return this; + } + + public ScenarioBuilder WithActiveFaction(Faction faction) + { + _setupActions.Add(() => SetActiveFaction(faction)); + return this; + } + + public ScenarioBuilder FromSaveFile(string saveFileName) + { + _setupActions.Add(() => LoadSaveFile(saveFileName)); + return this; + } + + // Execute all setup actions + public IEnumerator Build() + { + foreach (var action in _setupActions) + { + yield return action(); + yield return null; // Allow state to propagate + } + _setupActions.Clear(); + } + + // Private implementation methods + private IEnumerator SpawnUnit(Faction faction, Hex position, int mp) + { + var unit = _gameState.SpawnUnit(faction, position); + unit.MovementPoints = mp; + yield return null; + } + + private IEnumerator SetTerrain(Hex position, TerrainType terrain) + { + _gameState.Map.SetTerrain(position, terrain); + yield return null; + } + + private IEnumerator SetTurn(int turn) + { + _gameState.SetTurnNumber(turn); + yield return null; + } + + private IEnumerator SetPhase(TurnPhase phase) + { + _gameState.SetPhase(phase); + yield return null; + } + + private IEnumerator SetActiveFaction(Faction faction) + { + _gameState.SetActiveFaction(faction); + yield return null; + } + + private IEnumerator LoadSaveFile(string fileName) + { + var path = $"TestData/{fileName}"; + yield return _gameState.LoadGame(path); + } +} +``` + +**Usage:** + +```csharp +yield return Scenario + .WithUnit(Faction.Player, new Hex(3, 4), movementPoints: 6) + .WithUnit(Faction.Enemy, new Hex(5, 4)) + .WithTerrain(new Hex(4, 4), TerrainType.Forest) + .OnTurn(1) + .WithActiveFaction(Faction.Player) + .Build(); +``` + +### 3. Input Simulator + +Abstract player input for deterministic testing. Don't simulate raw mouse positions — simulate player *intent*. + +**Unity Example (New Input System):** + +```csharp +using UnityEngine; +using UnityEngine.InputSystem; + +public class InputSimulator +{ + private Mouse _mouse; + private Keyboard _keyboard; + private Camera _camera; + + public InputSimulator() + { + _mouse = Mouse.current ?? InputSystem.AddDevice(); + _keyboard = Keyboard.current ?? InputSystem.AddDevice(); + _camera = Camera.main; + } + + public IEnumerator ClickWorldPosition(Vector3 worldPos) + { + var screenPos = _camera.WorldToScreenPoint(worldPos); + yield return ClickScreenPosition(screenPos); + } + + public IEnumerator ClickHex(Hex hex) + { + var worldPos = HexUtils.HexToWorld(hex); + yield return ClickWorldPosition(worldPos); + } + + public IEnumerator ClickScreenPosition(Vector2 screenPos) + { + // Move mouse + InputSystem.QueueStateEvent(_mouse, new MouseState { position = screenPos }); + yield return null; + + // Press + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = screenPos, + buttons = 1 + }); + yield return null; + + // Release + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = screenPos, + buttons = 0 + }); + yield return null; + } + + public IEnumerator ClickButton(string buttonName) + { + var button = GameObject.Find(buttonName)?.GetComponent(); + Assert.IsNotNull(button, $"Button '{buttonName}' not found"); + + button.onClick.Invoke(); + yield return null; + } + + public IEnumerator DragFromTo(Vector3 from, Vector3 to, float duration = 0.5f) + { + var fromScreen = _camera.WorldToScreenPoint(from); + var toScreen = _camera.WorldToScreenPoint(to); + + // Start drag + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = fromScreen, + buttons = 1 + }); + yield return null; + + // Interpolate drag + var elapsed = 0f; + while (elapsed < duration) + { + var t = elapsed / duration; + var pos = Vector2.Lerp(fromScreen, toScreen, t); + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = pos, + buttons = 1 + }); + yield return null; + elapsed += Time.deltaTime; + } + + // End drag + InputSystem.QueueStateEvent(_mouse, new MouseState + { + position = toScreen, + buttons = 0 + }); + yield return null; + } + + public IEnumerator PressKey(Key key) + { + _keyboard.SetKeyDown(key); + yield return null; + _keyboard.SetKeyUp(key); + yield return null; + } + + public void Reset() + { + // Reset any held state + if (_mouse != null) + { + InputSystem.QueueStateEvent(_mouse, new MouseState()); + } + } +} +``` + +### 4. Async Assertions + +Wait-for-condition assertions with meaningful failure messages. The timeout and message are critical — when tests fail, you need to know *what* it was waiting for. + +**Unity Example:** + +```csharp +using System; +using System.Collections; +using NUnit.Framework; +using UnityEngine; + +public static class AsyncAssert +{ + /// + /// Wait until condition is true, or fail with message after timeout. + /// + public static IEnumerator WaitUntil( + Func condition, + string description, + float timeout = 5f) + { + var elapsed = 0f; + while (!condition() && elapsed < timeout) + { + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout}s waiting for: {description}"); + } + + /// + /// Wait until condition is true, with periodic logging. + /// + public static IEnumerator WaitUntilVerbose( + Func condition, + string description, + float timeout = 5f, + float logInterval = 1f) + { + var elapsed = 0f; + var lastLog = 0f; + + while (!condition() && elapsed < timeout) + { + if (elapsed - lastLog >= logInterval) + { + Debug.Log($"[E2E] Still waiting for: {description} ({elapsed:F1}s)"); + lastLog = elapsed; + } + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout}s waiting for: {description}"); + } + + /// + /// Wait for a specific value, with descriptive failure. + /// Note: For floating-point comparisons, use WaitForValueApprox instead + /// to handle precision issues. This method uses exact equality. + /// + public static IEnumerator WaitForValue( + Func getter, + T expected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => expected.Equals(getter()), + $"{description} to equal {expected} (current: {getter()})", + timeout); + } + + /// + /// Wait for a float value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func getter, + float expected, + string description, + float tolerance = 0.0001f, + float timeout = 5f) + { + yield return WaitUntil( + () => Mathf.Abs(expected - getter()) < tolerance, + $"{description} to equal ~{expected} ±{tolerance} (current: {getter()})", + timeout); + } + + /// + /// Wait for a double value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func getter, + double expected, + string description, + double tolerance = 0.0001, + float timeout = 5f) + { + yield return WaitUntil( + () => Math.Abs(expected - getter()) < tolerance, + $"{description} to equal ~{expected} ±{tolerance} (current: {getter()})", + timeout); + } + + /// + /// Wait for an event to fire. + /// + public static IEnumerator WaitForEvent( + Action> subscribe, + Action> unsubscribe, + string eventName, + float timeout = 5f) where T : class + { + T received = null; + Action handler = e => received = e; + + subscribe(handler); + + yield return WaitUntil( + () => received != null, + $"Event '{eventName}' to fire", + timeout); + + unsubscribe(handler); + } + + /// + /// Assert that something does NOT happen within a time window. + /// + public static IEnumerator WaitAndAssertNot( + Func condition, + string description, + float duration = 1f) + { + var elapsed = 0f; + while (elapsed < duration) + { + Assert.IsFalse(condition(), + $"Condition unexpectedly became true: {description}"); + yield return null; + elapsed += Time.deltaTime; + } + } +} +``` + +## E2E Test Patterns + +### Given-When-Then with Async + +The core pattern for E2E tests. Clear structure, readable intent. + +```csharp +[UnityTest] +public IEnumerator PlayerCanMoveUnitThroughZOC() +{ + // GIVEN: Soviet unit adjacent to German ZOC + yield return Scenario + .WithUnit(Faction.Soviet, new Hex(3, 4), movementPoints: 6) + .WithUnit(Faction.German, new Hex(4, 4)) // Creates ZOC at adjacent hexes + .WithActiveFaction(Faction.Soviet) + .Build(); + + // WHEN: Player selects unit and moves through ZOC + yield return Input.ClickHex(new Hex(3, 4)); // Select unit + yield return AsyncAssert.WaitUntil( + () => GameState.Selection.HasSelectedUnit, + "Unit should be selected"); + + yield return Input.ClickHex(new Hex(5, 4)); // Click destination (through ZOC) + + // THEN: Unit arrives with reduced movement points (ZOC cost) + yield return AsyncAssert.WaitUntil( + () => GetUnitAt(new Hex(5, 4)) != null, + "Unit should arrive at destination"); + + var unit = GetUnitAt(new Hex(5, 4)); + Assert.Less(unit.MovementPoints, 3, + "ZOC passage should cost extra movement points"); +} +``` + +### Full Turn Cycle + +Testing the complete turn lifecycle. + +```csharp +[UnityTest] +public IEnumerator FullTurnCycle_PlayerToAIAndBack() +{ + // GIVEN: Mid-game state with both factions having units + yield return Scenario + .FromSaveFile("mid_game_scenario.json") + .Build(); + + var startingTurn = GameState.TurnNumber; + + // WHEN: Player ends their turn + yield return Input.ClickButton("EndPhaseButton"); + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentPhase == TurnPhase.EndPhaseConfirmation, + "End phase confirmation"); + + yield return Input.ClickButton("ConfirmButton"); + + // THEN: AI executes its turn + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentFaction == Faction.AI, + "AI turn should begin"); + + // AND: Eventually returns to player + yield return AsyncAssert.WaitUntil( + () => GameState.CurrentFaction == Faction.Player, + "Player turn should return", + timeout: 30f); // AI might take a while + + Assert.AreEqual(startingTurn + 1, GameState.TurnNumber, + "Turn number should increment"); +} +``` + +### Save/Load Round-Trip + +Critical for any game with persistence. + +```csharp +[UnityTest] +public IEnumerator SaveLoad_PreservesGameState() +{ + // GIVEN: Game in specific state + yield return Scenario + .WithUnit(Faction.Player, new Hex(5, 5), movementPoints: 3) + .OnTurn(7) + .Build(); + + var unitPosition = new Hex(5, 5); + var originalMP = GetUnitAt(unitPosition).MovementPoints; + var originalTurn = GameState.TurnNumber; + + // WHEN: Save and reload + var savePath = "test_save_roundtrip"; + yield return GameState.SaveGame(savePath); + + // Trash the current state + yield return SceneManager.LoadSceneAsync(SceneName); + yield return WaitForGameReady(); + + // Load the save + yield return GameState.LoadGame(savePath); + yield return WaitForGameReady(); + + // THEN: State is preserved + Assert.AreEqual(originalTurn, GameState.TurnNumber, + "Turn number should be preserved"); + + var loadedUnit = GetUnitAt(unitPosition); + Assert.IsNotNull(loadedUnit, "Unit should exist at saved position"); + Assert.AreEqual(originalMP, loadedUnit.MovementPoints, + "Movement points should be preserved"); + + // Cleanup + var savedFilePath = GameState.GetSavePath(savePath); + if (System.IO.File.Exists(savedFilePath)) + { + try + { + System.IO.File.Delete(savedFilePath); + } + catch (System.IO.IOException ex) + { + Debug.LogWarning($"[E2E] Failed to delete test save file '{savedFilePath}': {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + Debug.LogWarning($"[E2E] Access denied deleting test save file '{savedFilePath}': {ex.Message}"); + } + } +} +``` + +### UI Flow Testing + +Testing complete UI journeys. + +```csharp +[UnityTest] +public IEnumerator MainMenu_NewGame_ReachesGameplay() +{ + // GIVEN: At main menu + yield return SceneManager.LoadSceneAsync("MainMenu"); + yield return null; + + // WHEN: Start new game flow + yield return Input.ClickButton("NewGameButton"); + yield return AsyncAssert.WaitUntil( + () => FindPanel("DifficultySelect") != null, + "Difficulty selection should appear"); + + yield return Input.ClickButton("NormalDifficultyButton"); + yield return Input.ClickButton("StartButton"); + + // THEN: Game scene loads and is playable + yield return AsyncAssert.WaitUntil( + () => SceneManager.GetActiveScene().name == "GameScene", + "Game scene should load", + timeout: 10f); + + yield return WaitForGameReady(); + + Assert.AreEqual(TurnPhase.PlayerMovement, GameState.CurrentPhase, + "Should start in player movement phase"); +} +``` + +## What to E2E Test + +### High Priority (Test These) + +| Category | Why | Examples | +|----------|-----|----------| +| Core gameplay loop | 90% of player time | Select → Move → Attack → End Turn | +| Turn/phase transitions | State machine boundaries | Phase changes, turn handoff | +| Save → Load → Resume | Data integrity | Full round-trip with verification | +| Win/lose conditions | Critical path endpoints | Victory triggers, game over | +| Critical UI flows | First impressions | Menu → Game → Pause → Resume | + +### Medium Priority (Test Key Paths) + +| Category | Why | Examples | +|----------|-----|----------| +| Undo/redo | Easy to break | Action reversal | +| Multiplayer sync | Complex state | Turn handoff in MP | +| Tutorial flow | First-time experience | Guided sequence | + +### Low Priority (Usually Skip for E2E) + +| Category | Why | Better Tested By | +|----------|-----|------------------| +| Edge cases | Combinatorial explosion | Unit tests | +| Visual correctness | Subjective, changes often | Manual testing | +| Performance | Needs dedicated tooling | Performance tests | +| Every permutation | Infinite combinations | Unit + integration | +| AI decision quality | Subjective | Playtesting | + +## E2E Test Organization + +``` +Tests/ +├── EditMode/ +│ └── ... (existing unit tests) +├── PlayMode/ +│ ├── Integration/ +│ │ └── ... (existing integration tests) +│ └── E2E/ +│ ├── E2E.asmdef +│ ├── Infrastructure/ +│ │ ├── GameE2ETestFixture.cs +│ │ ├── ScenarioBuilder.cs +│ │ ├── InputSimulator.cs +│ │ └── AsyncAssert.cs +│ ├── Scenarios/ +│ │ ├── TurnCycleE2ETests.cs +│ │ ├── MovementE2ETests.cs +│ │ ├── CombatE2ETests.cs +│ │ ├── SaveLoadE2ETests.cs +│ │ └── UIFlowE2ETests.cs +│ └── TestData/ +│ ├── mid_game_scenario.json +│ ├── endgame_scenario.json +│ └── edge_case_setup.json +``` + +### Assembly Definition for E2E + +```json +{ + "name": "E2E", + "references": [ + "GameAssembly" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll", + "UnityEngine.TestRunner.dll", + "UnityEditor.TestRunner.dll" + ], + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "autoReferenced": false +} +``` + +## CI Considerations + +E2E tests are slower and potentially flaky. Handle with care. + +### Separate CI Job + +```yaml +# GitHub Actions example +e2e-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: game-ci/unity-test-runner@v4 + with: + testMode: PlayMode + projectPath: . + customParameters: -testCategory E2E +``` + +### Retry Strategy + +```yaml +# Retry flaky tests once before failing +- uses: nick-fields/retry@v2 + with: + timeout_minutes: 15 + max_attempts: 2 + command: | + unity-test-runner --category E2E +``` + +### Failure Artifacts + +Capture screenshots and logs on failure: + +```csharp +[UnityTearDown] +public IEnumerator CaptureOnFailure() +{ + // Yield first to ensure we're on the main thread for screenshot capture + yield return null; + + if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed) + { + var screenshot = ScreenCapture.CaptureScreenshotAsTexture(); + var bytes = screenshot.EncodeToPNG(); + var screenshotPath = $"TestResults/Screenshots/{TestContext.CurrentContext.Test.Name}.png"; + System.IO.File.WriteAllBytes(screenshotPath, bytes); + + Debug.Log($"[E2E FAILURE] Screenshot saved: {screenshotPath}"); + } +} +``` + +### Execution Frequency + +| Suite | When | Timeout | +|-------|------|---------| +| Unit tests | Every commit | 5 min | +| Integration | Every commit | 10 min | +| E2E (smoke) | Every commit | 15 min | +| E2E (full) | Nightly | 60 min | + +## Avoiding Flaky Tests + +E2E tests are notorious for flakiness. Fight it proactively. + +### DO + +- Use explicit waits with `AsyncAssert.WaitUntil` +- Wait for *game state*, not time +- Clean up thoroughly in TearDown +- Isolate tests completely +- Use deterministic scenarios +- Seed random number generators + +### DON'T + +- Use `yield return new WaitForSeconds(x)` as primary sync +- Depend on test execution order +- Share state between tests +- Rely on animation timing +- Assume frame-perfect timing +- Skip cleanup "because it's slow" + +### Debugging Flaky Tests + +```csharp +// Add verbose logging to track down race conditions +[UnityTest] +public IEnumerator FlakyTest_WithDebugging() +{ + Debug.Log($"[E2E] Test start: {Time.frameCount}"); + + yield return Scenario.Build(); + Debug.Log($"[E2E] Scenario built: {Time.frameCount}"); + + yield return Input.ClickHex(targetHex); + Debug.Log($"[E2E] Input sent: {Time.frameCount}, Selection: {GameState.Selection}"); + + yield return AsyncAssert.WaitUntilVerbose( + () => ExpectedCondition(), + "Expected condition", + timeout: 10f, + logInterval: 0.5f); +} +``` + +## Engine-Specific Notes + +### Unity + +- Use `[UnityTest]` attribute for coroutine-based tests +- Prefer `WaitUntil` over `WaitForSeconds` +- Use `Object.FindFirstObjectByType()` (not the deprecated `FindObjectOfType`) +- Consider `InputTestFixture` for Input System simulation +- Remember: `yield return null` waits one frame + +### Unreal + +- Use `FFunctionalTest` actors for level-based E2E +- `LatentIt` for async test steps in Automation Framework +- Gauntlet for extended E2E suites running in isolated processes +- `ADD_LATENT_AUTOMATION_COMMAND` for sequenced operations + +### Godot + +- Use `await` for async operations in GUT +- `await get_tree().create_timer(x).timeout` for timed waits +- Scene instancing provides natural test isolation +- Use `queue_free()` for cleanup, not `free()` (avoids errors) + +## Anti-Patterns + +### The "Test Everything" Trap + +Don't try to E2E test every edge case. That's what unit tests are for. + +```csharp +// BAD: Testing edge case via E2E +[UnityTest] +public IEnumerator Movement_WithExactlyZeroMP_CannotMove() // Unit test this +{ + // 30 seconds of setup for a condition unit tests cover +} + +// GOOD: E2E tests the journey, unit tests the edge cases +[UnityTest] +public IEnumerator Movement_TypicalPlayerJourney_WorksCorrectly() +{ + // Tests the common path players actually experience +} +``` + +### The "Magic Sleep" Pattern + +```csharp +// BAD: Hoping 2 seconds is enough +yield return new WaitForSeconds(2f); +Assert.IsTrue(condition); + +// GOOD: Wait for the actual condition +yield return AsyncAssert.WaitUntil(() => condition, "description"); +``` + +### The "Shared State" Trap + +```csharp +// BAD: Tests pollute each other +private static int testCounter = 0; // Shared between tests! + +// GOOD: Each test is isolated +[SetUp] +public void Setup() +{ + // Fresh state every test +} +``` + +## Measuring E2E Test Value + +### Coverage Metrics That Matter + +- **Journey coverage**: How many critical player paths are tested? +- **Failure detection rate**: How many real bugs do E2E tests catch? +- **False positive rate**: How often do E2E tests fail spuriously? + +### Warning Signs + +- E2E suite takes > 30 minutes +- Flaky test rate > 5% +- E2E tests duplicate unit test coverage +- Team skips E2E tests because they're "always broken" + +### Health Indicators + +- E2E tests catch integration bugs unit tests miss +- New features include E2E tests for happy path +- Flaky tests are fixed or removed within a sprint +- E2E suite runs on every PR (at least smoke subset) diff --git a/src/modules/bmgd/gametest/knowledge/godot-testing.md b/src/modules/bmgd/gametest/knowledge/godot-testing.md index e282be22..ab79e093 100644 --- a/src/modules/bmgd/gametest/knowledge/godot-testing.md +++ b/src/modules/bmgd/gametest/knowledge/godot-testing.md @@ -374,3 +374,502 @@ test: | Signal not detected | Signal not watched | Call `watch_signals()` before action | | Physics not working | Missing frames | Await `physics_frame` | | Flaky tests | Timing issues | Use proper await/signals | + +## C# Testing in Godot + +Godot 4 supports C# via .NET 6+. You can use standard .NET testing frameworks alongside GUT. + +### Project Setup for C# + +``` +project/ +├── addons/ +│ └── gut/ +├── src/ +│ ├── Player/ +│ │ └── PlayerController.cs +│ └── Combat/ +│ └── DamageCalculator.cs +├── tests/ +│ ├── gdscript/ +│ │ └── test_integration.gd +│ └── csharp/ +│ ├── Tests.csproj +│ └── DamageCalculatorTests.cs +└── project.csproj +``` + +### C# Test Project Setup + +Create a separate test project that references your game assembly: + +```xml + + + + net6.0 + true + false + + + + + + + + + + + + + +``` + +### Basic C# Unit Tests + +```csharp +// tests/csharp/DamageCalculatorTests.cs +using Xunit; +using YourGame.Combat; + +public class DamageCalculatorTests +{ + private readonly DamageCalculator _calculator; + + public DamageCalculatorTests() + { + _calculator = new DamageCalculator(); + } + + [Fact] + public void Calculate_BaseDamage_ReturnsCorrectValue() + { + var result = _calculator.Calculate(100f, 1f); + Assert.Equal(100f, result); + } + + [Fact] + public void Calculate_CriticalHit_DoublesDamage() + { + var result = _calculator.Calculate(100f, 2f); + Assert.Equal(200f, result); + } + + [Theory] + [InlineData(100f, 0.5f, 50f)] + [InlineData(100f, 1.5f, 150f)] + [InlineData(50f, 2f, 100f)] + public void Calculate_Parameterized_ReturnsExpected( + float baseDamage, float multiplier, float expected) + { + var result = _calculator.Calculate(baseDamage, multiplier); + Assert.Equal(expected, result); + } +} +``` + +### Testing Godot Nodes in C# + +For tests requiring Godot runtime, use a hybrid approach: + +```csharp +// tests/csharp/PlayerControllerTests.cs +using Godot; +using Xunit; +using YourGame.Player; + +public class PlayerControllerTests : IDisposable +{ + private readonly SceneTree _sceneTree; + private PlayerController _player; + + public PlayerControllerTests() + { + // These tests must run within Godot runtime + // Use GodotXUnit or similar adapter + } + + [GodotFact] // Custom attribute for Godot runtime tests + public async Task Player_Move_ChangesPosition() + { + var startPos = _player.GlobalPosition; + + _player.SetInput(new Vector2(1, 0)); + + await ToSignal(GetTree().CreateTimer(0.5f), "timeout"); + + Assert.True(_player.GlobalPosition.X > startPos.X); + } + + public void Dispose() + { + _player?.QueueFree(); + } +} +``` + +### C# Mocking with NSubstitute + +```csharp +using NSubstitute; +using Xunit; + +public class EnemyAITests +{ + [Fact] + public void Enemy_UsesPathfinding_WhenMoving() + { + var mockPathfinding = Substitute.For(); + mockPathfinding.FindPath(Arg.Any(), Arg.Any()) + .Returns(new[] { Vector2.Zero, new Vector2(10, 10) }); + + var enemy = new EnemyAI(mockPathfinding); + + enemy.MoveTo(new Vector2(10, 10)); + + mockPathfinding.Received().FindPath( + Arg.Any(), + Arg.Is(v => v == new Vector2(10, 10))); + } +} +``` + +### Running C# Tests + +```bash +# Run C# unit tests (no Godot runtime needed) +dotnet test tests/csharp/Tests.csproj + +# Run with coverage +dotnet test tests/csharp/Tests.csproj --collect:"XPlat Code Coverage" + +# Run specific test +dotnet test tests/csharp/Tests.csproj --filter "FullyQualifiedName~DamageCalculator" +``` + +### Hybrid Test Strategy + +| Test Type | Framework | When to Use | +| ------------- | ---------------- | ---------------------------------- | +| Pure logic | xUnit/NUnit (C#) | Classes without Godot dependencies | +| Node behavior | GUT (GDScript) | MonoBehaviour-like testing | +| Integration | GUT (GDScript) | Scene and signal testing | +| E2E | GUT (GDScript) | Full gameplay flows | + +## End-to-End Testing + +For comprehensive E2E testing patterns, infrastructure scaffolding, and +scenario builders, see **knowledge/e2e-testing.md**. + +### E2E Infrastructure for Godot + +#### GameE2ETestFixture (GDScript) + +```gdscript +# tests/e2e/infrastructure/game_e2e_test_fixture.gd +extends GutTest +class_name GameE2ETestFixture + +var game_state: GameStateManager +var input_sim: InputSimulator +var scenario: ScenarioBuilder +var _scene_instance: Node + +## Override to specify a different scene for specific test classes. +func get_scene_path() -> String: + return "res://scenes/game.tscn" + +func before_each(): + # Load game scene + var scene = load(get_scene_path()) + _scene_instance = scene.instantiate() + add_child(_scene_instance) + + # Get references + game_state = _scene_instance.get_node("GameStateManager") + assert_not_null(game_state, "GameStateManager not found in scene") + + input_sim = InputSimulator.new() + scenario = ScenarioBuilder.new(game_state) + + # Wait for ready + await wait_for_game_ready() + +func after_each(): + if _scene_instance: + _scene_instance.queue_free() + _scene_instance = null + input_sim = null + scenario = null + +func wait_for_game_ready(timeout: float = 10.0): + var elapsed = 0.0 + while not game_state.is_ready and elapsed < timeout: + await get_tree().process_frame + elapsed += get_process_delta_time() + assert_true(game_state.is_ready, "Game should be ready within timeout") +``` + +#### ScenarioBuilder (GDScript) + +```gdscript +# tests/e2e/infrastructure/scenario_builder.gd +extends RefCounted +class_name ScenarioBuilder + +var _game_state: GameStateManager +var _setup_actions: Array[Callable] = [] + +func _init(game_state: GameStateManager): + _game_state = game_state + +## Load a pre-configured scenario from a save file. +func from_save_file(file_name: String) -> ScenarioBuilder: + _setup_actions.append(func(): await _load_save_file(file_name)) + return self + +## Configure the current turn number. +func on_turn(turn_number: int) -> ScenarioBuilder: + _setup_actions.append(func(): _set_turn(turn_number)) + return self + +## Spawn a unit at position. +func with_unit(faction: int, position: Vector2, movement_points: int = 6) -> ScenarioBuilder: + _setup_actions.append(func(): await _spawn_unit(faction, position, movement_points)) + return self + +## Execute all configured setup actions. +func build() -> void: + for action in _setup_actions: + await action.call() + _setup_actions.clear() + +## Clear pending actions without executing. +func reset() -> void: + _setup_actions.clear() + +# Private implementation +func _load_save_file(file_name: String) -> void: + var path = "res://tests/e2e/test_data/%s" % file_name + await _game_state.load_game(path) + +func _set_turn(turn: int) -> void: + _game_state.set_turn_number(turn) + +func _spawn_unit(faction: int, pos: Vector2, mp: int) -> void: + var unit = _game_state.spawn_unit(faction, pos) + unit.movement_points = mp +``` + +#### InputSimulator (GDScript) + +```gdscript +# tests/e2e/infrastructure/input_simulator.gd +extends RefCounted +class_name InputSimulator + +## Click at a world position. +func click_world_position(world_pos: Vector2) -> void: + var viewport = Engine.get_main_loop().root.get_viewport() + var camera = viewport.get_camera_2d() + var screen_pos = camera.get_screen_center_position() + (world_pos - camera.global_position) + await click_screen_position(screen_pos) + +## Click at a screen position. +func click_screen_position(screen_pos: Vector2) -> void: + var press = InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + press.position = screen_pos + + var release = InputEventMouseButton.new() + release.button_index = MOUSE_BUTTON_LEFT + release.pressed = false + release.position = screen_pos + + Input.parse_input_event(press) + await Engine.get_main_loop().process_frame + Input.parse_input_event(release) + await Engine.get_main_loop().process_frame + +## Click a UI button by name. +func click_button(button_name: String) -> void: + var root = Engine.get_main_loop().root + var button = _find_button_recursive(root, button_name) + assert(button != null, "Button '%s' not found in scene tree" % button_name) + + if not button.visible: + push_warning("[InputSimulator] Button '%s' is not visible" % button_name) + if button.disabled: + push_warning("[InputSimulator] Button '%s' is disabled" % button_name) + + button.pressed.emit() + await Engine.get_main_loop().process_frame + +func _find_button_recursive(node: Node, button_name: String) -> Button: + if node is Button and node.name == button_name: + return node + for child in node.get_children(): + var found = _find_button_recursive(child, button_name) + if found: + return found + return null + +## Press and release a key. +func press_key(keycode: Key) -> void: + var press = InputEventKey.new() + press.keycode = keycode + press.pressed = true + + var release = InputEventKey.new() + release.keycode = keycode + release.pressed = false + + Input.parse_input_event(press) + await Engine.get_main_loop().process_frame + Input.parse_input_event(release) + await Engine.get_main_loop().process_frame + +## Simulate an input action. +func action_press(action_name: String) -> void: + Input.action_press(action_name) + await Engine.get_main_loop().process_frame + +func action_release(action_name: String) -> void: + Input.action_release(action_name) + await Engine.get_main_loop().process_frame + +## Reset all input state. +func reset() -> void: + Input.flush_buffered_events() +``` + +#### AsyncAssert (GDScript) + +```gdscript +# tests/e2e/infrastructure/async_assert.gd +extends RefCounted +class_name AsyncAssert + +## Wait until condition is true, or fail after timeout. +static func wait_until( + condition: Callable, + description: String, + timeout: float = 5.0 +) -> void: + var elapsed := 0.0 + while not condition.call() and elapsed < timeout: + await Engine.get_main_loop().process_frame + elapsed += Engine.get_main_loop().root.get_process_delta_time() + + assert(condition.call(), + "Timeout after %.1fs waiting for: %s" % [timeout, description]) + +## Wait for a value to equal expected. +static func wait_for_value( + getter: Callable, + expected: Variant, + description: String, + timeout: float = 5.0 +) -> void: + await wait_until( + func(): return getter.call() == expected, + "%s to equal '%s' (current: '%s')" % [description, expected, getter.call()], + timeout) + +## Wait for a float value within tolerance. +static func wait_for_value_approx( + getter: Callable, + expected: float, + description: String, + tolerance: float = 0.0001, + timeout: float = 5.0 +) -> void: + await wait_until( + func(): return absf(expected - getter.call()) < tolerance, + "%s to equal ~%s ±%s (current: %s)" % [description, expected, tolerance, getter.call()], + timeout) + +## Assert that condition does NOT become true within duration. +static func assert_never_true( + condition: Callable, + description: String, + duration: float = 1.0 +) -> void: + var elapsed := 0.0 + while elapsed < duration: + assert(not condition.call(), + "Condition unexpectedly became true: %s" % description) + await Engine.get_main_loop().process_frame + elapsed += Engine.get_main_loop().root.get_process_delta_time() + +## Wait for specified number of frames. +static func wait_frames(count: int) -> void: + for i in range(count): + await Engine.get_main_loop().process_frame + +## Wait for physics to settle. +static func wait_for_physics(frames: int = 3) -> void: + for i in range(frames): + await Engine.get_main_loop().root.get_tree().physics_frame +``` + +### Example E2E Test (GDScript) + +```gdscript +# tests/e2e/scenarios/test_combat_flow.gd +extends GameE2ETestFixture + +func test_player_can_attack_enemy(): + # GIVEN: Player and enemy in combat range + await scenario \ + .with_unit(Faction.PLAYER, Vector2(100, 100)) \ + .with_unit(Faction.ENEMY, Vector2(150, 100)) \ + .build() + + var enemy = game_state.get_units(Faction.ENEMY)[0] + var initial_health = enemy.health + + # WHEN: Player attacks + await input_sim.click_world_position(Vector2(100, 100)) # Select player + await AsyncAssert.wait_until( + func(): return game_state.selected_unit != null, + "Unit should be selected") + + await input_sim.click_world_position(Vector2(150, 100)) # Attack enemy + + # THEN: Enemy takes damage + await AsyncAssert.wait_until( + func(): return enemy.health < initial_health, + "Enemy should take damage") + +func test_turn_cycle_completes(): + # GIVEN: Game in progress + await scenario.on_turn(1).build() + var starting_turn = game_state.turn_number + + # WHEN: Player ends turn + await input_sim.click_button("EndTurnButton") + await AsyncAssert.wait_until( + func(): return game_state.current_faction == Faction.ENEMY, + "Should switch to enemy turn") + + # AND: Enemy turn completes + await AsyncAssert.wait_until( + func(): return game_state.current_faction == Faction.PLAYER, + "Should return to player turn", + 30.0) # AI might take a while + + # THEN: Turn number incremented + assert_eq(game_state.turn_number, starting_turn + 1) +``` + +### Quick E2E Checklist for Godot + +- [ ] Create `GameE2ETestFixture` base class extending GutTest +- [ ] Implement `ScenarioBuilder` for your game's domain +- [ ] Create `InputSimulator` wrapping Godot Input +- [ ] Add `AsyncAssert` utilities with proper await +- [ ] Organize E2E tests under `tests/e2e/scenarios/` +- [ ] Configure GUT to include E2E test directory +- [ ] Set up CI with headless Godot execution diff --git a/src/modules/bmgd/gametest/knowledge/unity-testing.md b/src/modules/bmgd/gametest/knowledge/unity-testing.md index f1b872d9..f057933c 100644 --- a/src/modules/bmgd/gametest/knowledge/unity-testing.md +++ b/src/modules/bmgd/gametest/knowledge/unity-testing.md @@ -381,3 +381,17 @@ test: | NullReferenceException | Missing Setup | Ensure [SetUp] initializes all fields | | Tests hang | Infinite coroutine | Add timeout or max iterations | | Flaky physics tests | Timing dependent | Use WaitForFixedUpdate, increase tolerance | + +## End-to-End Testing + +For comprehensive E2E testing patterns, infrastructure scaffolding, and +scenario builders, see **knowledge/e2e-testing.md**. + +### Quick E2E Checklist for Unity + +- [ ] Create `GameE2ETestFixture` base class +- [ ] Implement `ScenarioBuilder` for your game's domain +- [ ] Create `InputSimulator` wrapping Input System +- [ ] Add `AsyncAssert` utilities +- [ ] Organize E2E tests under `Tests/PlayMode/E2E/` +- [ ] Configure separate CI job for E2E suite diff --git a/src/modules/bmgd/gametest/knowledge/unreal-testing.md b/src/modules/bmgd/gametest/knowledge/unreal-testing.md index 0863bd0c..3b8f668d 100644 --- a/src/modules/bmgd/gametest/knowledge/unreal-testing.md +++ b/src/modules/bmgd/gametest/knowledge/unreal-testing.md @@ -386,3 +386,1129 @@ test: | Crash in test | Missing world | Use proper test context | | Flaky results | Timing issues | Use latent commands | | Slow tests | Too many actors | Optimize test setup | + +## End-to-End Testing + +For comprehensive E2E testing patterns, infrastructure scaffolding, and +scenario builders, see **knowledge/e2e-testing.md**. + +### E2E Infrastructure for Unreal + +E2E tests in Unreal leverage Functional Tests with custom infrastructure for scenario setup, input simulation, and async assertions. + +#### Project Structure + +``` +Source/ +├── MyGame/ +│ └── ... (game code) +└── MyGameTests/ + ├── MyGameTests.Build.cs + ├── Public/ + │ ├── GameE2ETestBase.h + │ ├── ScenarioBuilder.h + │ ├── InputSimulator.h + │ └── AsyncTestHelpers.h + ├── Private/ + │ ├── GameE2ETestBase.cpp + │ ├── ScenarioBuilder.cpp + │ ├── InputSimulator.cpp + │ ├── AsyncTestHelpers.cpp + │ └── E2E/ + │ ├── CombatE2ETests.cpp + │ ├── TurnCycleE2ETests.cpp + │ └── SaveLoadE2ETests.cpp + └── TestMaps/ + ├── E2E_Combat.umap + └── E2E_TurnCycle.umap +``` + +#### Test Module Build File + +```cpp +// MyGameTests.Build.cs +using UnrealBuildTool; + +public class MyGameTests : ModuleRules +{ + public MyGameTests(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicDependencyModuleNames.AddRange(new string[] { + "Core", + "CoreUObject", + "Engine", + "InputCore", + "EnhancedInput", + "MyGame" + }); + + PrivateDependencyModuleNames.AddRange(new string[] { + "FunctionalTesting", + "AutomationController" + }); + + // Only include in editor/test builds + if (Target.bBuildDeveloperTools || Target.Configuration == UnrealTargetConfiguration.Debug) + { + PrecompileForTargets = PrecompileTargetsType.Any; + } + } +} +``` + +#### GameE2ETestBase (Base Class) + +```cpp +// GameE2ETestBase.h +#pragma once + +#include "CoreMinimal.h" +#include "FunctionalTest.h" +#include "GameE2ETestBase.generated.h" + +class UScenarioBuilder; +class UInputSimulator; +class UGameStateManager; + +/** + * Base class for all E2E functional tests. + * Provides scenario setup, input simulation, and async assertion utilities. + */ +UCLASS(Abstract) +class MYGAMETESTS_API AGameE2ETestBase : public AFunctionalTest +{ + GENERATED_BODY() + +public: + AGameE2ETestBase(); + +protected: + /** Game state manager reference, found automatically on test start. */ + UPROPERTY(BlueprintReadOnly, Category = "E2E") + UGameStateManager* GameState; + + /** Input simulation utility. */ + UPROPERTY(BlueprintReadOnly, Category = "E2E") + UInputSimulator* InputSim; + + /** Scenario configuration builder. */ + UPROPERTY(BlueprintReadOnly, Category = "E2E") + UScenarioBuilder* Scenario; + + /** Timeout for waiting operations (seconds). */ + UPROPERTY(EditAnywhere, Category = "E2E") + float DefaultTimeout = 10.0f; + + // AFunctionalTest interface + virtual void PrepareTest() override; + virtual void StartTest() override; + virtual void CleanUp() override; + + /** Override to specify custom game state class. */ + virtual TSubclassOf GetGameStateClass() const; + + /** + * Wait until game state reports ready. + * Calls OnGameReady() when complete or fails test on timeout. + */ + UFUNCTION(BlueprintCallable, Category = "E2E") + void WaitForGameReady(); + + /** Called when game is ready. Override to begin test logic. */ + virtual void OnGameReady(); + + /** + * Wait until condition is true, then call callback. + * Fails test if timeout exceeded. + */ + void WaitUntil(TFunction Condition, const FString& Description, + TFunction OnComplete, float Timeout = -1.0f); + + /** + * Wait for a specific value, then call callback. + */ + template + void WaitForValue(TFunction Getter, T Expected, + const FString& Description, TFunction OnComplete, + float Timeout = -1.0f); + + /** + * Assert condition and fail test with message if false. + */ + void AssertTrue(bool Condition, const FString& Message); + + /** + * Assert values are equal within tolerance. + */ + void AssertNearlyEqual(float Actual, float Expected, + const FString& Message, float Tolerance = 0.0001f); + +private: + FTimerHandle WaitTimerHandle; + float WaitElapsed; + float WaitTimeout; + TFunction WaitCondition; + TFunction WaitCallback; + FString WaitDescription; + + void TickWaitCondition(); +}; +``` + +```cpp +// GameE2ETestBase.cpp +#include "GameE2ETestBase.h" +#include "ScenarioBuilder.h" +#include "InputSimulator.h" +#include "GameStateManager.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "Kismet/GameplayStatics.h" + +AGameE2ETestBase::AGameE2ETestBase() +{ + // Default test settings + TimeLimit = 120.0f; // 2 minute max for E2E tests + TimesUpMessage = TEXT("E2E test exceeded time limit"); +} + +void AGameE2ETestBase::PrepareTest() +{ + Super::PrepareTest(); + + // Create utilities + InputSim = NewObject(this); + Scenario = NewObject(this); +} + +void AGameE2ETestBase::StartTest() +{ + Super::StartTest(); + + // Find game state manager + TSubclassOf GameStateClass = GetGameStateClass(); + TArray FoundActors; + UGameplayStatics::GetAllActorsOfClass(GetWorld(), GameStateClass, FoundActors); + + if (FoundActors.Num() > 0) + { + GameState = Cast( + FoundActors[0]->GetComponentByClass(GameStateClass)); + } + + if (!GameState) + { + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("GameStateManager not found in test world"))); + return; + } + + // Initialize scenario builder with game state + Scenario->Initialize(GameState); + + // Wait for game to be ready + WaitForGameReady(); +} + +void AGameE2ETestBase::CleanUp() +{ + // Clear timer + if (WaitTimerHandle.IsValid()) + { + GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle); + } + + // Reset input state + if (InputSim) + { + InputSim->Reset(); + } + + Super::CleanUp(); +} + +TSubclassOf AGameE2ETestBase::GetGameStateClass() const +{ + return UGameStateManager::StaticClass(); +} + +void AGameE2ETestBase::WaitForGameReady() +{ + WaitUntil( + [this]() { return GameState && GameState->IsReady(); }, + TEXT("Game to reach ready state"), + [this]() { OnGameReady(); }, + DefaultTimeout + ); +} + +void AGameE2ETestBase::OnGameReady() +{ + // Override in derived classes to begin test logic +} + +void AGameE2ETestBase::WaitUntil( + TFunction Condition, + const FString& Description, + TFunction OnComplete, + float Timeout) +{ + WaitCondition = Condition; + WaitCallback = OnComplete; + WaitDescription = Description; + WaitElapsed = 0.0f; + WaitTimeout = (Timeout < 0.0f) ? DefaultTimeout : Timeout; + + // Check immediately + if (WaitCondition()) + { + WaitCallback(); + return; + } + + // Set up polling timer + GetWorld()->GetTimerManager().SetTimer( + WaitTimerHandle, + this, + &AGameE2ETestBase::TickWaitCondition, + 0.1f, // Check every 100ms + true + ); +} + +void AGameE2ETestBase::TickWaitCondition() +{ + WaitElapsed += 0.1f; + + if (WaitCondition()) + { + GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle); + WaitCallback(); + } + else if (WaitElapsed >= WaitTimeout) + { + GetWorld()->GetTimerManager().ClearTimer(WaitTimerHandle); + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("Timeout after %.1fs waiting for: %s"), + WaitTimeout, *WaitDescription)); + } +} + +void AGameE2ETestBase::AssertTrue(bool Condition, const FString& Message) +{ + if (!Condition) + { + FinishTest(EFunctionalTestResult::Failed, Message); + } +} + +void AGameE2ETestBase::AssertNearlyEqual( + float Actual, float Expected, + const FString& Message, float Tolerance) +{ + if (!FMath::IsNearlyEqual(Actual, Expected, Tolerance)) + { + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("%s: Expected ~%f, got %f"), + *Message, Expected, Actual)); + } +} +``` + +#### ScenarioBuilder + +```cpp +// ScenarioBuilder.h +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "ScenarioBuilder.generated.h" + +class UGameStateManager; + +/** + * Fluent API for configuring E2E test scenarios. + */ +UCLASS(BlueprintType) +class MYGAMETESTS_API UScenarioBuilder : public UObject +{ + GENERATED_BODY() + +public: + /** Initialize with game state reference. */ + void Initialize(UGameStateManager* InGameState); + + /** + * Load scenario from save file. + * @param FileName Save file name (without path) + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* FromSaveFile(const FString& FileName); + + /** + * Set the current turn number. + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* OnTurn(int32 TurnNumber); + + /** + * Set the active faction. + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* WithActiveFaction(EFaction Faction); + + /** + * Spawn a unit at position. + * @param Faction Unit's faction + * @param Position World position + * @param MovementPoints Starting movement points + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* WithUnit(EFaction Faction, FVector Position, + int32 MovementPoints = 6); + + /** + * Set terrain at position. + */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + UScenarioBuilder* WithTerrain(FVector Position, ETerrainType Terrain); + + /** + * Execute all queued setup actions. + * @param OnComplete Called when all actions complete + */ + void Build(TFunction OnComplete); + + /** Clear pending actions without executing. */ + UFUNCTION(BlueprintCallable, Category = "Scenario") + void Reset(); + +private: + UPROPERTY() + UGameStateManager* GameState; + + TArray)>> SetupActions; + + void ExecuteNextAction(int32 Index, TFunction FinalCallback); +}; +``` + +```cpp +// ScenarioBuilder.cpp +#include "ScenarioBuilder.h" +#include "GameStateManager.h" + +void UScenarioBuilder::Initialize(UGameStateManager* InGameState) +{ + GameState = InGameState; + SetupActions.Empty(); +} + +UScenarioBuilder* UScenarioBuilder::FromSaveFile(const FString& FileName) +{ + SetupActions.Add([this, FileName](TFunction Done) { + FString Path = FString::Printf(TEXT("TestData/%s"), *FileName); + GameState->LoadGame(Path, FOnLoadComplete::CreateLambda([Done](bool bSuccess) { + Done(); + })); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::OnTurn(int32 TurnNumber) +{ + SetupActions.Add([this, TurnNumber](TFunction Done) { + GameState->SetTurnNumber(TurnNumber); + Done(); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::WithActiveFaction(EFaction Faction) +{ + SetupActions.Add([this, Faction](TFunction Done) { + GameState->SetActiveFaction(Faction); + Done(); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::WithUnit( + EFaction Faction, FVector Position, int32 MovementPoints) +{ + SetupActions.Add([this, Faction, Position, MovementPoints](TFunction Done) { + AUnit* Unit = GameState->SpawnUnit(Faction, Position); + if (Unit) + { + Unit->SetMovementPoints(MovementPoints); + } + Done(); + }); + return this; +} + +UScenarioBuilder* UScenarioBuilder::WithTerrain( + FVector Position, ETerrainType Terrain) +{ + SetupActions.Add([this, Position, Terrain](TFunction Done) { + GameState->GetMap()->SetTerrain(Position, Terrain); + Done(); + }); + return this; +} + +void UScenarioBuilder::Build(TFunction OnComplete) +{ + if (SetupActions.Num() == 0) + { + OnComplete(); + return; + } + + ExecuteNextAction(0, OnComplete); +} + +void UScenarioBuilder::Reset() +{ + SetupActions.Empty(); +} + +void UScenarioBuilder::ExecuteNextAction( + int32 Index, TFunction FinalCallback) +{ + if (Index >= SetupActions.Num()) + { + SetupActions.Empty(); + FinalCallback(); + return; + } + + SetupActions[Index]([this, Index, FinalCallback]() { + ExecuteNextAction(Index + 1, FinalCallback); + }); +} +``` + +#### InputSimulator + +```cpp +// InputSimulator.h +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "InputCoreTypes.h" +#include "InputSimulator.generated.h" + +class APlayerController; + +/** + * Simulates player input for E2E tests. + */ +UCLASS(BlueprintType) +class MYGAMETESTS_API UInputSimulator : public UObject +{ + GENERATED_BODY() + +public: + /** + * Click at a world position. + * @param WorldPos Position in world space + * @param OnComplete Called when click completes + */ + void ClickWorldPosition(FVector WorldPos, TFunction OnComplete); + + /** + * Click at screen coordinates. + */ + void ClickScreenPosition(FVector2D ScreenPos, TFunction OnComplete); + + /** + * Click a UI button by name. + * @param ButtonName Name of the button widget + * @param OnComplete Called when click completes + */ + UFUNCTION(BlueprintCallable, Category = "Input") + void ClickButton(const FString& ButtonName, TFunction OnComplete); + + /** + * Press and release a key. + */ + void PressKey(FKey Key, TFunction OnComplete); + + /** + * Trigger an input action. + */ + void TriggerAction(FName ActionName, TFunction OnComplete); + + /** + * Drag from one position to another. + */ + void DragFromTo(FVector From, FVector To, float Duration, + TFunction OnComplete); + + /** Reset all input state. */ + UFUNCTION(BlueprintCallable, Category = "Input") + void Reset(); + +private: + APlayerController* GetPlayerController() const; + void SimulateMouseClick(FVector2D ScreenPos, TFunction OnComplete); +}; +``` + +```cpp +// InputSimulator.cpp +#include "InputSimulator.h" +#include "GameFramework/PlayerController.h" +#include "Blueprint/UserWidget.h" +#include "Components/Button.h" +#include "Blueprint/WidgetBlueprintLibrary.h" +#include "Kismet/GameplayStatics.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "Framework/Application/SlateApplication.h" + +void UInputSimulator::ClickWorldPosition( + FVector WorldPos, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + FVector2D ScreenPos; + if (PC->ProjectWorldLocationToScreen(WorldPos, ScreenPos, true)) + { + ClickScreenPosition(ScreenPos, OnComplete); + } + else + { + OnComplete(); + } +} + +void UInputSimulator::ClickScreenPosition( + FVector2D ScreenPos, TFunction OnComplete) +{ + SimulateMouseClick(ScreenPos, OnComplete); +} + +void UInputSimulator::ClickButton( + const FString& ButtonName, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + UE_LOG(LogTemp, Warning, + TEXT("[InputSimulator] No PlayerController found")); + OnComplete(); + return; + } + + // Find button in all widgets + TArray FoundWidgets; + UWidgetBlueprintLibrary::GetAllWidgetsOfClass( + PC->GetWorld(), FoundWidgets, UUserWidget::StaticClass(), false); + + UButton* TargetButton = nullptr; + for (UUserWidget* Widget : FoundWidgets) + { + if (UButton* Button = Cast( + Widget->GetWidgetFromName(FName(*ButtonName)))) + { + TargetButton = Button; + break; + } + } + + if (TargetButton) + { + if (!TargetButton->GetIsEnabled()) + { + UE_LOG(LogTemp, Warning, + TEXT("[InputSimulator] Button '%s' is not enabled"), *ButtonName); + } + + // Simulate click via delegate + TargetButton->OnClicked.Broadcast(); + + // Delay to allow UI to process + FTimerHandle TimerHandle; + PC->GetWorld()->GetTimerManager().SetTimer( + TimerHandle, + [OnComplete]() { OnComplete(); }, + 0.1f, + false + ); + } + else + { + UE_LOG(LogTemp, Warning, + TEXT("[InputSimulator] Button '%s' not found"), *ButtonName); + OnComplete(); + } +} + +void UInputSimulator::PressKey(FKey Key, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + // Simulate key press + FInputKeyEventArgs PressArgs(PC->GetLocalPlayer()->GetControllerId(), + Key, EInputEvent::IE_Pressed, 1.0f, false); + PC->InputKey(PressArgs); + + // Delay then release + FTimerHandle TimerHandle; + PC->GetWorld()->GetTimerManager().SetTimer( + TimerHandle, + [this, PC, Key, OnComplete]() { + FInputKeyEventArgs ReleaseArgs(PC->GetLocalPlayer()->GetControllerId(), + Key, EInputEvent::IE_Released, 0.0f, false); + PC->InputKey(ReleaseArgs); + OnComplete(); + }, + 0.1f, + false + ); +} + +void UInputSimulator::TriggerAction(FName ActionName, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + // For Enhanced Input System + if (UEnhancedInputComponent* EIC = Cast( + PC->InputComponent.Get())) + { + // Trigger the action through the input subsystem + // Implementation depends on your input action setup + } + + OnComplete(); +} + +void UInputSimulator::DragFromTo( + FVector From, FVector To, float Duration, TFunction OnComplete) +{ + APlayerController* PC = GetPlayerController(); + if (!PC) + { + OnComplete(); + return; + } + + FVector2D FromScreen, ToScreen; + PC->ProjectWorldLocationToScreen(From, FromScreen, true); + PC->ProjectWorldLocationToScreen(To, ToScreen, true); + + // Simulate drag start + FSlateApplication::Get().ProcessMouseButtonDownEvent( + nullptr, FPointerEvent( + 0, FromScreen, FromScreen, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + + // Interpolate drag over duration + float Elapsed = 0.0f; + float Interval = 0.05f; + + FTimerHandle DragTimer; + PC->GetWorld()->GetTimerManager().SetTimer( + DragTimer, + [this, PC, FromScreen, ToScreen, Duration, &Elapsed, Interval, OnComplete, &DragTimer]() { + Elapsed += Interval; + float Alpha = FMath::Clamp(Elapsed / Duration, 0.0f, 1.0f); + FVector2D CurrentPos = FMath::Lerp(FromScreen, ToScreen, Alpha); + + FSlateApplication::Get().ProcessMouseMoveEvent( + FPointerEvent( + 0, CurrentPos, CurrentPos - FVector2D(1, 0), + TSet({EKeys::LeftMouseButton}), + FModifierKeysState() + ) + ); + + if (Alpha >= 1.0f) + { + PC->GetWorld()->GetTimerManager().ClearTimer(DragTimer); + + FSlateApplication::Get().ProcessMouseButtonUpEvent( + FPointerEvent( + 0, ToScreen, ToScreen, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + + OnComplete(); + } + }, + Interval, + true + ); +} + +void UInputSimulator::Reset() +{ + // Release any held inputs + FSlateApplication::Get().ClearAllUserFocus(); +} + +APlayerController* UInputSimulator::GetPlayerController() const +{ + UWorld* World = GEngine->GetWorldContexts()[0].World(); + return World ? UGameplayStatics::GetPlayerController(World, 0) : nullptr; +} + +void UInputSimulator::SimulateMouseClick( + FVector2D ScreenPos, TFunction OnComplete) +{ + // Press + FSlateApplication::Get().ProcessMouseButtonDownEvent( + nullptr, FPointerEvent( + 0, ScreenPos, ScreenPos, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + + // Delay then release + UWorld* World = GEngine->GetWorldContexts()[0].World(); + if (World) + { + FTimerHandle TimerHandle; + World->GetTimerManager().SetTimer( + TimerHandle, + [ScreenPos, OnComplete]() { + FSlateApplication::Get().ProcessMouseButtonUpEvent( + FPointerEvent( + 0, ScreenPos, ScreenPos, TSet(), + EKeys::LeftMouseButton, 0, FModifierKeysState() + ) + ); + OnComplete(); + }, + 0.1f, + false + ); + } + else + { + OnComplete(); + } +} +``` + +#### AsyncTestHelpers + +```cpp +// AsyncTestHelpers.h +#pragma once + +#include "CoreMinimal.h" +#include "Misc/AutomationTest.h" + +/** + * Latent command to wait for a condition. + */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER( + FWaitUntilCondition, + TFunction, Condition, + FString, Description, + float, Timeout +); + +/** + * Latent command to wait for a value to equal expected. + */ +template +class FWaitForValue : public IAutomationLatentCommand +{ +public: + FWaitForValue(TFunction InGetter, T InExpected, + const FString& InDescription, float InTimeout) + : Getter(InGetter) + , Expected(InExpected) + , Description(InDescription) + , Timeout(InTimeout) + , Elapsed(0.0f) + {} + + virtual bool Update() override + { + Elapsed += FApp::GetDeltaTime(); + + if (Getter() == Expected) + { + return true; + } + + if (Elapsed >= Timeout) + { + UE_LOG(LogTemp, Error, + TEXT("Timeout after %.1fs waiting for: %s"), + Timeout, *Description); + return true; + } + + return false; + } + +private: + TFunction Getter; + T Expected; + FString Description; + float Timeout; + float Elapsed; +}; + +/** + * Latent command to wait for float value within tolerance. + */ +class FWaitForValueApprox : public IAutomationLatentCommand +{ +public: + FWaitForValueApprox(TFunction InGetter, float InExpected, + const FString& InDescription, + float InTolerance = 0.0001f, float InTimeout = 5.0f) + : Getter(InGetter) + , Expected(InExpected) + , Description(InDescription) + , Tolerance(InTolerance) + , Timeout(InTimeout) + , Elapsed(0.0f) + {} + + virtual bool Update() override + { + Elapsed += FApp::GetDeltaTime(); + + if (FMath::IsNearlyEqual(Getter(), Expected, Tolerance)) + { + return true; + } + + if (Elapsed >= Timeout) + { + UE_LOG(LogTemp, Error, + TEXT("Timeout after %.1fs waiting for: %s (expected ~%f, got %f)"), + Timeout, *Description, Expected, Getter()); + return true; + } + + return false; + } + +private: + TFunction Getter; + float Expected; + FString Description; + float Tolerance; + float Timeout; + float Elapsed; +}; + +/** + * Latent command to assert condition never becomes true. + */ +DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER( + FAssertNeverTrue, + TFunction, Condition, + FString, Description, + float, Duration +); + +/** Helper macros for E2E tests */ +#define E2E_WAIT_UNTIL(Cond, Desc, Timeout) \ + ADD_LATENT_AUTOMATION_COMMAND(FWaitUntilCondition(Cond, Desc, Timeout)) + +#define E2E_WAIT_FOR_VALUE(Getter, Expected, Desc, Timeout) \ + ADD_LATENT_AUTOMATION_COMMAND(FWaitForValue(Getter, Expected, Desc, Timeout)) + +#define E2E_WAIT_FOR_FLOAT(Getter, Expected, Desc, Tolerance, Timeout) \ + ADD_LATENT_AUTOMATION_COMMAND(FWaitForValueApprox(Getter, Expected, Desc, Tolerance, Timeout)) +``` + +### Example E2E Test + +```cpp +// CombatE2ETests.cpp +#include "GameE2ETestBase.h" +#include "ScenarioBuilder.h" +#include "InputSimulator.h" +#include "AsyncTestHelpers.h" + +/** + * E2E test: Player can attack enemy and deal damage. + */ +UCLASS() +class AE2E_PlayerAttacksEnemy : public AGameE2ETestBase +{ + GENERATED_BODY() + +protected: + virtual void OnGameReady() override + { + // GIVEN: Player and enemy units in combat range + Scenario + ->WithUnit(EFaction::Player, FVector(100, 100, 0), 6) + ->WithUnit(EFaction::Enemy, FVector(200, 100, 0), 6) + ->WithActiveFaction(EFaction::Player) + ->Build([this]() { OnScenarioReady(); }); + } + +private: + void OnScenarioReady() + { + // Store enemy reference and initial health + TArray Enemies = GameState->GetUnits(EFaction::Enemy); + if (Enemies.Num() == 0) + { + FinishTest(EFunctionalTestResult::Failed, TEXT("No enemy found")); + return; + } + + AUnit* Enemy = Enemies[0]; + float InitialHealth = Enemy->GetHealth(); + + // WHEN: Player selects unit and attacks + InputSim->ClickWorldPosition(FVector(100, 100, 0), [this]() { + WaitUntil( + [this]() { return GameState->GetSelectedUnit() != nullptr; }, + TEXT("Unit should be selected"), + [this, Enemy, InitialHealth]() { PerformAttack(Enemy, InitialHealth); } + ); + }); + } + + void PerformAttack(AUnit* Enemy, float InitialHealth) + { + // Click on enemy to attack + InputSim->ClickWorldPosition(Enemy->GetActorLocation(), [this, Enemy, InitialHealth]() { + // THEN: Enemy takes damage + WaitUntil( + [Enemy, InitialHealth]() { return Enemy->GetHealth() < InitialHealth; }, + TEXT("Enemy should take damage"), + [this]() { + FinishTest(EFunctionalTestResult::Succeeded, + TEXT("Player successfully attacked enemy")); + } + ); + }); + } +}; + +/** + * E2E test: Full turn cycle completes correctly. + */ +UCLASS() +class AE2E_TurnCycleCompletes : public AGameE2ETestBase +{ + GENERATED_BODY() + +protected: + int32 StartingTurn; + + virtual void OnGameReady() override + { + // GIVEN: Game in progress + Scenario + ->OnTurn(1) + ->WithActiveFaction(EFaction::Player) + ->Build([this]() { OnScenarioReady(); }); + } + +private: + void OnScenarioReady() + { + StartingTurn = GameState->GetTurnNumber(); + + // WHEN: Player ends turn + InputSim->ClickButton(TEXT("EndTurnButton"), [this]() { + WaitUntil( + [this]() { + return GameState->GetActiveFaction() == EFaction::Enemy; + }, + TEXT("Should switch to enemy turn"), + [this]() { WaitForPlayerTurnReturn(); } + ); + }); + } + + void WaitForPlayerTurnReturn() + { + // Wait for AI turn to complete + WaitUntil( + [this]() { + return GameState->GetActiveFaction() == EFaction::Player; + }, + TEXT("Should return to player turn"), + [this]() { VerifyTurnIncremented(); }, + 30.0f // AI might take a while + ); + } + + void VerifyTurnIncremented() + { + // THEN: Turn number incremented + int32 CurrentTurn = GameState->GetTurnNumber(); + if (CurrentTurn == StartingTurn + 1) + { + FinishTest(EFunctionalTestResult::Succeeded, + TEXT("Turn cycle completed successfully")); + } + else + { + FinishTest(EFunctionalTestResult::Failed, + FString::Printf(TEXT("Expected turn %d, got %d"), + StartingTurn + 1, CurrentTurn)); + } + } +}; +``` + +### Running E2E Tests + +```bash +# Run all E2E tests +UnrealEditor-Cmd.exe MyGame.uproject \ + -ExecCmds="Automation RunTests MyGame.E2E" \ + -unattended -nopause -nullrhi + +# Run specific E2E test +UnrealEditor-Cmd.exe MyGame.uproject \ + -ExecCmds="Automation RunTests MyGame.E2E.Combat.PlayerAttacksEnemy" \ + -unattended -nopause + +# Run with detailed logging +UnrealEditor-Cmd.exe MyGame.uproject \ + -ExecCmds="Automation RunTests MyGame.E2E" \ + -unattended -nopause -log=E2ETests.log +``` + +### Quick E2E Checklist for Unreal + +- [ ] Create `GameE2ETestBase` class extending `AFunctionalTest` +- [ ] Implement `ScenarioBuilder` for your game's domain +- [ ] Create `InputSimulator` wrapping Slate input system +- [ ] Add `AsyncTestHelpers` with latent commands +- [ ] Create dedicated E2E test maps with spawn points +- [ ] Organize E2E tests under `Source/MyGameTests/Private/E2E/` +- [ ] Configure separate CI job for E2E suite with extended timeout +- [ ] Use Gauntlet for extended E2E scenarios if needed diff --git a/src/modules/bmgd/gametest/qa-index.csv b/src/modules/bmgd/gametest/qa-index.csv index af026afd..05b3ba79 100644 --- a/src/modules/bmgd/gametest/qa-index.csv +++ b/src/modules/bmgd/gametest/qa-index.csv @@ -14,4 +14,5 @@ input-testing,Input Testing,"Controller, keyboard, and touch input validation"," localization-testing,Localization Testing,"Text, audio, and cultural validation for international releases","localization,i18n,text",knowledge/localization-testing.md certification-testing,Platform Certification,"Console TRC/XR requirements and certification testing","certification,console,trc,xr",knowledge/certification-testing.md smoke-testing,Smoke Testing,"Critical path validation for build verification","smoke-tests,bvt,ci",knowledge/smoke-testing.md -test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md \ No newline at end of file +test-priorities,Test Priorities Matrix,"P0-P3 criteria, coverage targets, execution ordering for games","prioritization,risk,coverage",knowledge/test-priorities.md +e2e-testing,End-to-End Testing,"Complete player journey testing with infrastructure patterns and async utilities","e2e,integration,player-journeys,scenarios,infrastructure",knowledge/e2e-testing.md diff --git a/src/modules/bmgd/workflows/gametest/automate/instructions.md b/src/modules/bmgd/workflows/gametest/automate/instructions.md index 2af2e4fe..8fb1615c 100644 --- a/src/modules/bmgd/workflows/gametest/automate/instructions.md +++ b/src/modules/bmgd/workflows/gametest/automate/instructions.md @@ -209,6 +209,87 @@ func test_{feature}_integration(): # Cleanup scene.queue_free() ``` +### E2E Journey Tests + +**Knowledge Base Reference**: `knowledge/e2e-testing.md` +```csharp +public class {Feature}E2ETests : GameE2ETestFixture +{ + [UnityTest] + public IEnumerator {JourneyName}_Succeeds() + { + // GIVEN + yield return Scenario + .{SetupMethod1}() + .{SetupMethod2}() + .Build(); + + // WHEN + yield return Input.{Action1}(); + yield return AsyncAssert.WaitUntil( + () => {Condition1}, "{Description1}"); + yield return Input.{Action2}(); + + // THEN + yield return AsyncAssert.WaitUntil( + () => {FinalCondition}, "{FinalDescription}"); + Assert.{Assertion}({expected}, {actual}); + } +} +``` + + +## Step 3.5: Generate E2E Infrastructure + +Before generating E2E tests, scaffold the required infrastructure. + +### Infrastructure Checklist + +1. **Test Fixture Base Class** + - Scene loading/unloading + - Game ready state waiting + - Common service access + - Cleanup guarantees + +2. **Scenario Builder** + - Fluent API for game state configuration + - Domain-specific methods (e.g., `WithUnit`, `OnTurn`) + - Yields for state propagation + +3. **Input Simulator** + - Click/drag abstractions + - Button press simulation + - Keyboard input queuing + +4. **Async Assertions** + - `WaitUntil` with timeout and message + - `WaitForEvent` for event-driven flows + - `WaitForState` for state machine transitions + +### Generation Template +```csharp +// GameE2ETestFixture.cs +public abstract class GameE2ETestFixture +{ + protected {GameStateClass} GameState; + protected {InputSimulatorClass} Input; + protected {ScenarioBuilderClass} Scenario; + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + yield return LoadScene("{main_scene}"); + GameState = Object.FindFirstObjectByType<{GameStateClass}>(); + Input = new {InputSimulatorClass}(); + Scenario = new {ScenarioBuilderClass}(GameState); + yield return WaitForReady(); + } + + // ... (fill from e2e-testing.md patterns) +} +``` + +**After scaffolding infrastructure, proceed to generate actual E2E tests.** --- diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md b/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md new file mode 100644 index 00000000..58a510d2 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/checklist.md @@ -0,0 +1,95 @@ +# E2E Infrastructure Scaffold Checklist + +## Preflight Validation + +- [ ] Test framework already initialized (`Tests/` directory exists with proper structure) +- [ ] Game state manager class identified +- [ ] Main gameplay scene identified and loads without errors +- [ ] No existing E2E infrastructure conflicts + +## Architecture Analysis + +- [ ] Game engine correctly detected +- [ ] Engine version identified +- [ ] Input system type determined (New Input System, Legacy, Custom) +- [ ] Game state manager class located +- [ ] Ready/initialized state property identified +- [ ] Key domain entities catalogued for ScenarioBuilder + +## Generated Files + +### Directory Structure +- [ ] `Tests/PlayMode/E2E/` directory created +- [ ] `Tests/PlayMode/E2E/Infrastructure/` directory created +- [ ] `Tests/PlayMode/E2E/Scenarios/` directory created +- [ ] `Tests/PlayMode/E2E/TestData/` directory created + +### Infrastructure Files +- [ ] `E2E.asmdef` created with correct assembly references +- [ ] `GameE2ETestFixture.cs` created with correct class references +- [ ] `ScenarioBuilder.cs` created with at least placeholder methods +- [ ] `InputSimulator.cs` created matching detected input system +- [ ] `AsyncAssert.cs` created with core assertion methods + +### Example and Documentation +- [ ] `ExampleE2ETest.cs` created with working infrastructure test +- [ ] `README.md` created with usage documentation + +## Code Quality + +### GameE2ETestFixture +- [ ] Correct namespace applied +- [ ] Correct `GameStateClass` reference +- [ ] Correct `SceneName` default +- [ ] `WaitForGameReady` uses correct ready property +- [ ] `UnitySetUp` and `UnityTearDown` properly structured +- [ ] Virtual methods for derived class customization + +### ScenarioBuilder +- [ ] Fluent API pattern correctly implemented +- [ ] `Build()` executes all queued actions +- [ ] At least one domain-specific method added (or clear TODOs) +- [ ] `FromSaveFile` method scaffolded + +### InputSimulator +- [ ] Matches detected input system (New vs Legacy) +- [ ] Mouse click simulation works +- [ ] Button click by name works +- [ ] Keyboard input scaffolded +- [ ] `Reset()` method cleans up state + +### AsyncAssert +- [ ] `WaitUntil` includes timeout and descriptive failure +- [ ] `WaitForValue` provides current vs expected in failure +- [ ] `AssertNeverTrue` for negative assertions +- [ ] Frame/physics wait utilities included + +## Assembly Definition + +- [ ] References main game assembly +- [ ] References Unity.InputSystem (if applicable) +- [ ] `overrideReferences` set to true +- [ ] `precompiledReferences` includes nunit.framework.dll +- [ ] `precompiledReferences` includes UnityEngine.TestRunner.dll +- [ ] `precompiledReferences` includes UnityEditor.TestRunner.dll +- [ ] `UNITY_INCLUDE_TESTS` define constraint set + +## Verification + +- [ ] Project compiles without errors after scaffold +- [ ] `ExampleE2ETests.Infrastructure_GameLoadsAndReachesReadyState` passes +- [ ] Test appears in Test Runner under PlayMode → E2E category + +## Documentation Quality + +- [ ] README explains all infrastructure components +- [ ] Quick start example is copy-pasteable +- [ ] Extension instructions are clear +- [ ] Troubleshooting table addresses common issues + +## Handoff + +- [ ] Summary output provided with all configuration values +- [ ] Next steps clearly listed +- [ ] Customization requirements highlighted +- [ ] Knowledge fragments referenced diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md b/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md new file mode 100644 index 00000000..42b99840 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/instructions.md @@ -0,0 +1,1137 @@ + + +# E2E Test Infrastructure Scaffold + +**Workflow ID**: `_bmad/bmgd/gametest/e2e-scaffold` +**Version**: 1.0 (BMad v6) + +--- + +## Overview + +Scaffold complete E2E testing infrastructure for an existing game project. This workflow creates the foundation required for reliable, maintainable end-to-end tests: test fixtures, scenario builders, input simulators, and async assertion utilities — all tailored to the project's specific architecture. + +E2E tests validate complete player journeys. Without proper infrastructure, they become brittle nightmares. This workflow prevents that. + +--- + +## Preflight Requirements + +**Critical:** Verify these requirements before proceeding. If any fail, HALT and guide the user. + +- ✅ Test framework already initialized (run `test-framework` workflow first) +- ✅ Game has identifiable state manager class +- ✅ Main gameplay scene exists and is functional +- ✅ No existing E2E infrastructure (check for `Tests/PlayMode/E2E/`) + +**Knowledge Base:** Load `knowledge/e2e-testing.md` before proceeding. + +--- + +## Step 1: Analyze Game Architecture + +### 1.1 Detect Game Engine + +Identify engine type by checking for: + +- **Unity**: `Assets/`, `ProjectSettings/`, `*.unity` scenes +- **Unreal**: `*.uproject`, `Source/`, `Config/DefaultEngine.ini` +- **Godot**: `project.godot`, `*.tscn`, `*.gd` files + +Load the appropriate engine-specific knowledge fragment: +- Unity: `knowledge/unity-testing.md` +- Unreal: `knowledge/unreal-testing.md` +- Godot: `knowledge/godot-testing.md` + +### 1.2 Identify Core Systems + +Locate and document: + +1. **Game State Manager** + - Primary class that holds game state + - Look for: `GameManager`, `GameStateManager`, `GameController`, `GameMode` + - Note: initialization method, ready state property, save/load methods + +2. **Input Handling** + - Unity: New Input System (`InputSystem` package) vs Legacy (`Input.GetKey`) + - Unreal: Enhanced Input vs Legacy + - Godot: Built-in Input singleton + - Custom input abstraction layer + +3. **Event/Messaging System** + - Event bus pattern + - C# events/delegates + - UnityEvents + - Signals (Godot) + +4. **Scene Structure** + - Main gameplay scene name + - Scene loading approach (additive, single) + - Bootstrap/initialization flow + +### 1.3 Identify Domain Concepts + +For the ScenarioBuilder, identify: + +- **Primary Entities**: Units, players, items, enemies, etc. +- **State Machine States**: Turn phases, game modes, player states +- **Spatial System**: Grid/hex positions, world coordinates, regions +- **Resources**: Currency, health, mana, ammunition, etc. + +### 1.4 Check Existing Test Structure + +``` +Expected structure after test-framework workflow: +Tests/ +├── EditMode/ +│ └── ... (unit tests) +└── PlayMode/ + └── ... (integration tests) +``` + +If `Tests/PlayMode/E2E/` already exists, HALT and ask user how to proceed. + +--- + +## Step 2: Generate Infrastructure + +### 2.1 Create Directory Structure + +``` +Tests/PlayMode/E2E/ +├── E2E.asmdef +├── Infrastructure/ +│ ├── GameE2ETestFixture.cs +│ ├── ScenarioBuilder.cs +│ ├── InputSimulator.cs +│ └── AsyncAssert.cs +├── Scenarios/ +│ └── (empty - user will add tests here) +├── TestData/ +│ └── (empty - user will add fixtures here) +└── README.md +``` + +### 2.2 Generate Assembly Definition + +**Unity: `E2E.asmdef`** + +```json +{ + "name": "E2E", + "rootNamespace": "{ProjectNamespace}.Tests.E2E", + "references": [ + "{GameAssemblyName}", + "Unity.InputSystem", + "Unity.InputSystem.TestFramework" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll", + "UnityEngine.TestRunner.dll", + "UnityEditor.TestRunner.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} +``` + +**Notes:** +- Replace `{ProjectNamespace}` with detected project namespace +- Replace `{GameAssemblyName}` with main game assembly +- Include `Unity.InputSystem` references only if Input System package detected + +### 2.3 Generate GameE2ETestFixture + +This is the base class all E2E tests inherit from. + +**Unity Template:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Base fixture for all E2E tests. Handles scene loading, game initialization, + /// and provides access to core test utilities. + /// + public abstract class GameE2ETestFixture + { + /// + /// Override to specify a different scene for specific test classes. + /// + protected virtual string SceneName => "{MainSceneName}"; + + /// + /// Primary game state manager reference. + /// + protected {GameStateClass} GameState { get; private set; } + + /// + /// Input simulation utility. + /// + protected InputSimulator Input { get; private set; } + + /// + /// Scenario configuration builder. + /// + protected ScenarioBuilder Scenario { get; private set; } + + [UnitySetUp] + public IEnumerator BaseSetUp() + { + // Load the game scene + yield return SceneManager.LoadSceneAsync(SceneName); + yield return null; // Wait one frame for Awake/Start + + // Get core references + GameState = Object.FindFirstObjectByType<{GameStateClass}>(); + Assert.IsNotNull(GameState, + $"{nameof({GameStateClass})} not found in scene '{SceneName}'"); + + // Initialize test utilities + Input = new InputSimulator(); + Scenario = new ScenarioBuilder(GameState); + + // Wait for game to reach ready state + yield return WaitForGameReady(); + + // Call derived class setup + yield return SetUp(); + } + + [UnityTearDown] + public IEnumerator BaseTearDown() + { + // Call derived class teardown first + yield return TearDown(); + + // Reset input state + Input?.Reset(); + + // Clear references + GameState = null; + Input = null; + Scenario = null; + } + + /// + /// Override for test-class-specific setup. Called after scene loads and game is ready. + /// + protected virtual IEnumerator SetUp() + { + yield return null; + } + + /// + /// Override for test-class-specific teardown. Called before base cleanup. + /// + protected virtual IEnumerator TearDown() + { + yield return null; + } + + /// + /// Waits until the game reaches a playable state. + /// + protected virtual IEnumerator WaitForGameReady(float timeout = 10f) + { + yield return AsyncAssert.WaitUntil( + () => GameState != null && GameState.{IsReadyProperty}, + "Game to reach ready state", + timeout); + } + + /// + /// Captures screenshot on test failure for debugging. + /// + protected IEnumerator CaptureFailureScreenshot() + { + if (TestContext.CurrentContext.Result.Outcome.Status == + NUnit.Framework.Interfaces.TestStatus.Failed) + { + var texture = ScreenCapture.CaptureScreenshotAsTexture(); + var bytes = texture.EncodeToPNG(); + var testName = TestContext.CurrentContext.Test.Name; + var path = $"TestResults/E2E_Failure_{testName}_{System.DateTime.Now:yyyyMMdd_HHmmss}.png"; + + System.IO.Directory.CreateDirectory("TestResults"); + System.IO.File.WriteAllBytes(path, bytes); + Debug.Log($"[E2E] Failure screenshot saved: {path}"); + + Object.Destroy(texture); + } + yield return null; + } + } +} +``` + +**Customization Points:** +- `{Namespace}`: Project namespace (e.g., `AugustStorm`) +- `{MainSceneName}`: Detected main gameplay scene +- `{GameStateClass}`: Identified game state manager class +- `{IsReadyProperty}`: Property indicating game is initialized (e.g., `IsReady`, `IsInitialized`) + +### 2.4 Generate ScenarioBuilder + +Fluent API for configuring test scenarios. This must be customized to the game's domain. + +**Unity Template:** + +```csharp +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Fluent builder for configuring E2E test scenarios. + /// Add domain-specific methods as needed for your game. + /// + public class ScenarioBuilder + { + private readonly {GameStateClass} _gameState; + private readonly List> _setupActions = new(); + + public ScenarioBuilder({GameStateClass} gameState) + { + _gameState = gameState; + } + + #region State Configuration + + /// + /// Load a pre-configured scenario from a save file. + /// + public ScenarioBuilder FromSaveFile(string fileName) + { + _setupActions.Add(() => LoadSaveFile(fileName)); + return this; + } + + // TODO: Add domain-specific configuration methods + // Examples for a turn-based strategy game: + // + // public ScenarioBuilder WithUnit(Faction faction, Hex position, int mp = 6) + // { + // _setupActions.Add(() => SpawnUnit(faction, position, mp)); + // return this; + // } + // + // public ScenarioBuilder OnTurn(int turnNumber) + // { + // _setupActions.Add(() => SetTurn(turnNumber)); + // return this; + // } + // + // public ScenarioBuilder WithActiveFaction(Faction faction) + // { + // _setupActions.Add(() => SetActiveFaction(faction)); + // return this; + // } + + #endregion + + #region Execution + + /// + /// Execute all configured setup actions. + /// + public IEnumerator Build() + { + foreach (var action in _setupActions) + { + yield return action(); + yield return null; // Allow state to propagate + } + _setupActions.Clear(); + } + + /// + /// Clear pending actions without executing. + /// + public void Reset() + { + _setupActions.Clear(); + } + + #endregion + + #region Private Implementation + + private IEnumerator LoadSaveFile(string fileName) + { + var path = $"TestData/{fileName}"; + // TODO: Implement save loading based on your save system + // yield return _gameState.LoadGame(path); + Debug.Log($"[ScenarioBuilder] Loading scenario from: {path}"); + yield return null; + } + + // TODO: Implement domain-specific setup methods + // private IEnumerator SpawnUnit(Faction faction, Hex position, int mp) + // { + // var unit = _gameState.SpawnUnit(faction, position); + // unit.MovementPoints = mp; + // yield return null; + // } + + #endregion + } +} +``` + +**Note to Agent:** After generating the template, analyze the game's domain model and add 3-5 concrete configuration methods based on identified entities (Step 1.3). + +### 2.5 Generate InputSimulator + +Abstract player input for deterministic testing. + +**Unity Template (New Input System):** + +```csharp +using System.Collections; +using UnityEngine; +using UnityEngine.InputSystem; +using UnityEngine.InputSystem.LowLevel; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Simulates player input for E2E tests. + /// + public class InputSimulator + { + private Mouse _mouse; + private Keyboard _keyboard; + private Camera _camera; + + public InputSimulator() + { + _mouse = Mouse.current ?? InputSystem.AddDevice(); + _keyboard = Keyboard.current ?? InputSystem.AddDevice(); + _camera = Camera.main; + } + + #region Mouse Input + + /// + /// Click at a world position. + /// + public IEnumerator ClickWorldPosition(Vector3 worldPos) + { + var screenPos = _camera.WorldToScreenPoint(worldPos); + yield return ClickScreenPosition(new Vector2(screenPos.x, screenPos.y)); + } + + /// + /// Click at a screen position. + /// + public IEnumerator ClickScreenPosition(Vector2 screenPos) + { + // Move mouse to position + InputState.Change(_mouse.position, screenPos); + yield return null; + + // Press + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + // Release + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + /// + /// Click a UI button by name. + /// + public IEnumerator ClickButton(string buttonName) + { + var button = GameObject.Find(buttonName)? + .GetComponent(); + + if (button == null) + { + // Search in inactive objects within loaded scenes only + var buttons = Object.FindObjectsByType( + FindObjectsInactive.Include, FindObjectsSortMode.None); + foreach (var b in buttons) + { + if (b.name == buttonName && b.gameObject.scene.isLoaded) + { + button = b; + break; + } + } + } + + UnityEngine.Assertions.Assert.IsNotNull(button, + $"Button '{buttonName}' not found in active scenes"); + + if (!button.interactable) + { + Debug.LogWarning($"[InputSimulator] Button '{buttonName}' is not interactable"); + } + + button.onClick.Invoke(); + yield return null; + } + + /// + /// Drag from one world position to another. + /// + public IEnumerator DragFromTo(Vector3 from, Vector3 to, float duration = 0.3f) + { + var fromScreen = (Vector2)_camera.WorldToScreenPoint(from); + var toScreen = (Vector2)_camera.WorldToScreenPoint(to); + + // Move to start + InputState.Change(_mouse.position, fromScreen); + yield return null; + + // Press + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + // Drag + var elapsed = 0f; + while (elapsed < duration) + { + var t = elapsed / duration; + var pos = Vector2.Lerp(fromScreen, toScreen, t); + InputState.Change(_mouse.position, pos); + yield return null; + elapsed += Time.deltaTime; + } + + // Release at destination + InputState.Change(_mouse.position, toScreen); + using (StateEvent.From(_mouse, out var eventPtr)) + { + _mouse.CopyState(eventPtr); + _mouse.leftButton.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + #endregion + + #region Keyboard Input + + /// + /// Press and release a key. + /// + public IEnumerator PressKey(Key key) + { + var control = _keyboard[key]; + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + /// + /// Hold a key for a duration. + /// + public IEnumerator HoldKey(Key key, float duration) + { + var control = _keyboard[key]; + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(1f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + + yield return new WaitForSeconds(duration); + + using (StateEvent.From(_keyboard, out var eventPtr)) + { + control.WriteValueIntoEvent(0f, eventPtr); + InputSystem.QueueEvent(eventPtr); + } + yield return null; + } + + #endregion + + #region Utility + + /// + /// Reset all input state. + /// + public void Reset() + { + if (_mouse != null) + { + InputState.Change(_mouse, new MouseState()); + } + if (_keyboard != null) + { + InputState.Change(_keyboard, new KeyboardState()); + } + } + + /// + /// Update camera reference (call after scene load if needed). + /// + public void RefreshCamera() + { + _camera = Camera.main; + } + + #endregion + } +} +``` + +**Unity Template (Legacy Input):** + +If legacy input system detected, generate a simpler version using `Input.mousePosition` simulation or UI event triggering. + +### 2.6 Generate AsyncAssert + +Wait-for-condition assertions with meaningful failure messages. + +**Unity Template:** + +```csharp +using System; +using System.Collections; +using NUnit.Framework; +using UnityEngine; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Async assertion utilities for E2E tests. + /// + public static class AsyncAssert + { + /// + /// Wait until condition is true, or fail with message after timeout. + /// + /// Condition to wait for + /// Human-readable description of what we're waiting for + /// Maximum seconds to wait + public static IEnumerator WaitUntil( + Func condition, + string description, + float timeout = 5f) + { + var elapsed = 0f; + while (!condition() && elapsed < timeout) + { + yield return null; + elapsed += Time.deltaTime; + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout:F1}s waiting for: {description}"); + } + + /// + /// Wait until condition is true, with periodic debug logging. + /// + public static IEnumerator WaitUntilVerbose( + Func condition, + string description, + float timeout = 5f, + float logInterval = 1f) + { + var elapsed = 0f; + var lastLog = 0f; + + while (!condition() && elapsed < timeout) + { + if (elapsed - lastLog >= logInterval) + { + Debug.Log($"[E2E] Waiting for: {description} ({elapsed:F1}s elapsed)"); + lastLog = elapsed; + } + yield return null; + elapsed += Time.deltaTime; + } + + if (condition()) + { + Debug.Log($"[E2E] Condition met: {description} (after {elapsed:F1}s)"); + } + + Assert.IsTrue(condition(), + $"Timeout after {timeout:F1}s waiting for: {description}"); + } + + /// + /// Wait for a value to equal expected. + /// Note: For floating-point comparisons, use WaitForValueApprox instead + /// to handle precision issues. This method uses exact equality. + /// + public static IEnumerator WaitForValue( + Func getter, + T expected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => expected.Equals(getter()), + $"{description} to equal '{expected}' (current: '{getter()}')", + timeout); + } + + /// + /// Wait for a float value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func getter, + float expected, + string description, + float tolerance = 0.0001f, + float timeout = 5f) + { + yield return WaitUntil( + () => Mathf.Abs(expected - getter()) < tolerance, + $"{description} to equal ~{expected} ±{tolerance} (current: {getter()})", + timeout); + } + + /// + /// Wait for a double value within tolerance (handles floating-point precision). + /// + public static IEnumerator WaitForValueApprox( + Func getter, + double expected, + string description, + double tolerance = 0.0001, + float timeout = 5f) + { + yield return WaitUntil( + () => Math.Abs(expected - getter()) < tolerance, + $"{description} to equal ~{expected} ±{tolerance} (current: {getter()})", + timeout); + } + + /// + /// Wait for a value to not equal a specific value. + /// + public static IEnumerator WaitForValueNot( + Func getter, + T notExpected, + string description, + float timeout = 5f) where T : IEquatable + { + yield return WaitUntil( + () => !notExpected.Equals(getter()), + $"{description} to change from '{notExpected}'", + timeout); + } + + /// + /// Wait for a reference to become non-null. + /// + public static IEnumerator WaitForNotNull( + Func getter, + string description, + float timeout = 5f) where T : class + { + yield return WaitUntil( + () => getter() != null, + $"{description} to exist (not null)", + timeout); + } + + /// + /// Wait for a Unity Object to exist (handles Unity's fake null). + /// + public static IEnumerator WaitForUnityObject( + Func getter, + string description, + float timeout = 5f) where T : UnityEngine.Object + { + yield return WaitUntil( + () => getter() != null, // Unity overloads == for destroyed objects + $"{description} to exist", + timeout); + } + + /// + /// Assert that a condition does NOT become true within a time window. + /// Useful for testing that something doesn't happen. + /// + public static IEnumerator AssertNeverTrue( + Func condition, + string description, + float duration = 1f) + { + var elapsed = 0f; + while (elapsed < duration) + { + Assert.IsFalse(condition(), + $"Condition unexpectedly became true: {description}"); + yield return null; + elapsed += Time.deltaTime; + } + } + + /// + /// Wait for a specific number of frames. + /// Use sparingly - prefer WaitUntil with conditions. + /// + public static IEnumerator WaitFrames(int frameCount) + { + for (int i = 0; i < frameCount; i++) + { + yield return null; + } + } + + /// + /// Wait for physics to settle (multiple FixedUpdates). + /// + public static IEnumerator WaitForPhysics(int fixedUpdateCount = 3) + { + for (int i = 0; i < fixedUpdateCount; i++) + { + yield return new WaitForFixedUpdate(); + } + } + } +} +``` + +--- + +## Step 3: Generate Example Test + +Create a working E2E test that exercises the infrastructure and proves it works. + +**Unity Template:** + +```csharp +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace {Namespace}.Tests.E2E +{ + /// + /// Example E2E tests demonstrating infrastructure usage. + /// Delete or modify these once you've verified the setup works. + /// + [Category("E2E")] + public class ExampleE2ETests : GameE2ETestFixture + { + [UnityTest] + public IEnumerator Infrastructure_GameLoadsAndReachesReadyState() + { + // This test verifies the E2E infrastructure is working correctly. + // If this test passes, your infrastructure is properly configured. + + // The base fixture already loaded the scene and waited for ready, + // so if we get here, everything worked. + + Assert.IsNotNull(GameState, "GameState should be available"); + Assert.IsNotNull(Input, "InputSimulator should be available"); + Assert.IsNotNull(Scenario, "ScenarioBuilder should be available"); + + // Verify game is actually ready + // NOTE: {IsReadyProperty} is a template placeholder. Replace it with your + // game's actual ready-state property (e.g., IsReady, IsInitialized, HasLoaded). + yield return AsyncAssert.WaitUntil( + () => GameState.{IsReadyProperty}, + "Game should be in ready state"); + + Debug.Log("[E2E] Infrastructure test passed - E2E framework is working!"); + } + + [UnityTest] + public IEnumerator Infrastructure_InputSimulatorCanClickButtons() + { + // Test that input simulation works + // Modify this to click an actual button in your game + + // Example: Click a button that should exist in your main scene + // yield return Input.ClickButton("SomeButtonName"); + // yield return AsyncAssert.WaitUntil( + // () => /* button click result */, + // "Button click should have effect"); + + Debug.Log("[E2E] Input simulation test - customize with your UI elements"); + yield return null; + } + + [UnityTest] + public IEnumerator Infrastructure_ScenarioBuilderCanConfigureState() + { + // Test that scenario builder works + // Modify this to use your domain-specific setup methods + + // Example: + // yield return Scenario + // .WithUnit(Faction.Player, new Hex(3, 3)) + // .OnTurn(1) + // .Build(); + // + // Assert.AreEqual(1, GameState.TurnNumber); + + Debug.Log("[E2E] Scenario builder test - customize with your domain methods"); + yield return Scenario.Build(); // Execute empty builder (no-op) + } + } +} +``` + +--- + +## Step 4: Generate Documentation + +Create a README explaining how to use the E2E infrastructure. + +**Template: `Tests/PlayMode/E2E/README.md`** + +```markdown +# E2E Testing Infrastructure + +End-to-end tests that validate complete player journeys through the game. + +## Quick Start + +1. Create a new test class inheriting from `GameE2ETestFixture` +2. Use `Scenario` to configure game state +3. Use `Input` to simulate player actions +4. Use `AsyncAssert` to wait for and verify outcomes + +## Example Test + +```csharp +[UnityTest] +public IEnumerator Player_CanCompleteBasicAction() +{ + // GIVEN: Configured scenario + yield return Scenario + .WithSomeSetup() + .Build(); + + // WHEN: Player takes action + yield return Input.ClickButton("ActionButton"); + + // THEN: Expected outcome occurs + yield return AsyncAssert.WaitUntil( + () => GameState.ActionCompleted, + "Action should complete"); +} +``` + +## Infrastructure Components + +### GameE2ETestFixture + +Base class for all E2E tests. Provides: +- Automatic scene loading and cleanup +- Access to `GameState`, `Input`, and `Scenario` +- Override `SetUp()` and `TearDown()` for test-specific setup + +### ScenarioBuilder + +Fluent API for configuring test scenarios. Extend with domain-specific methods: + +```csharp +// In ScenarioBuilder.cs, add methods like: +public ScenarioBuilder WithPlayer(Vector3 position) +{ + _setupActions.Add(() => SpawnPlayer(position)); + return this; +} +``` + +### InputSimulator + +Simulates player input: +- `ClickWorldPosition(Vector3)` - Click in 3D space +- `ClickScreenPosition(Vector2)` - Click at screen coordinates +- `ClickButton(string)` - Click UI button by name +- `DragFromTo(Vector3, Vector3)` - Drag gesture +- `PressKey(Key)` - Keyboard input + +### AsyncAssert + +Async assertions with timeouts: +- `WaitUntil(condition, description, timeout)` - Wait for condition +- `WaitForValue(getter, expected, description)` - Wait for specific value +- `AssertNeverTrue(condition, description, duration)` - Assert something doesn't happen + +## Directory Structure + +``` +E2E/ +├── Infrastructure/ # Base classes and utilities (don't modify often) +├── Scenarios/ # Your actual E2E tests go here +└── TestData/ # Save files and fixtures for scenarios +``` + +## Running Tests + +**In Unity Editor:** +- Window → General → Test Runner +- Select "PlayMode" tab +- Filter by "E2E" category + +**Command Line:** +```bash +unity -runTests -testPlatform PlayMode -testCategory E2E -batchmode +``` + +## Best Practices + +1. **Use Given-When-Then structure** for readable tests +2. **Wait for conditions, not time** - avoid `WaitForSeconds` as primary sync +3. **One journey per test** - keep tests focused +4. **Descriptive assertions** - include context in failure messages +5. **Clean up state** - don't let tests pollute each other + +## Extending the Framework + +### Adding Scenario Methods + +Edit `ScenarioBuilder.cs` to add domain-specific setup: + +```csharp +public ScenarioBuilder OnLevel(int level) +{ + _setupActions.Add(() => LoadLevel(level)); + return this; +} + +private IEnumerator LoadLevel(int level) +{ + _gameState.LoadLevel(level); + yield return null; +} +``` + +### Adding Input Methods + +Edit `InputSimulator.cs` for game-specific input: + +```csharp +public IEnumerator ClickHex(Hex hex) +{ + var worldPos = HexUtils.HexToWorld(hex); + yield return ClickWorldPosition(worldPos); +} +``` + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| Tests timeout waiting for ready | Game init takes too long | Increase timeout in `WaitForGameReady` | +| Input simulation doesn't work | Wrong input system | Check `InputSimulator` matches your setup | +| Flaky tests | Race conditions | Use `AsyncAssert.WaitUntil` instead of `WaitForSeconds` | +| Can't find GameState | Wrong scene or class name | Check `SceneName` and class reference | +``` + +--- + +## Step 5: Output Summary + +After generating all files, provide this summary: + +```markdown +## E2E Infrastructure Scaffold Complete + +**Engine**: {Unity | Unreal | Godot} +**Version**: {detected_version} + +### Files Created + +``` +Tests/PlayMode/E2E/ +├── E2E.asmdef +├── Infrastructure/ +│ ├── GameE2ETestFixture.cs +│ ├── ScenarioBuilder.cs +│ ├── InputSimulator.cs +│ └── AsyncAssert.cs +├── Scenarios/ +│ └── (empty) +├── TestData/ +│ └── (empty) +├── ExampleE2ETest.cs +└── README.md +``` + +### Configuration + +| Setting | Value | +|---------|-------| +| Game State Class | `{GameStateClass}` | +| Main Scene | `{MainSceneName}` | +| Input System | `{InputSystemType}` | +| Ready Property | `{IsReadyProperty}` | + +### Customization Required + +1. **ScenarioBuilder**: Add domain-specific setup methods for your game entities +2. **InputSimulator**: Add game-specific input methods (e.g., hex clicking, gesture shortcuts) +3. **ExampleE2ETest**: Modify example tests to use your actual UI elements + +### Next Steps + +1. ✅ Run `ExampleE2ETests.Infrastructure_GameLoadsAndReachesReadyState` to verify setup +2. 📝 Extend `ScenarioBuilder` with your domain methods +3. 📝 Extend `InputSimulator` with game-specific input helpers +4. 🧪 Use `test-design` workflow to identify E2E scenarios +5. 🤖 Use `automate` workflow to generate E2E tests from scenarios + +### Knowledge Applied + +- `knowledge/e2e-testing.md` - Core E2E patterns and infrastructure +- `knowledge/{engine}-testing.md` - Engine-specific implementation details +``` + +--- + +## Validation + +Refer to `checklist.md` for comprehensive validation criteria. diff --git a/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml b/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml new file mode 100644 index 00000000..03d7c465 --- /dev/null +++ b/src/modules/bmgd/workflows/gametest/e2e-scaffold/workflow.yaml @@ -0,0 +1,145 @@ +# E2E Test Infrastructure Scaffold Workflow + +workflow: + id: e2e-scaffold + name: E2E Test Infrastructure Scaffold + version: 1.0 + module: bmgd + agent: game-qa + + description: | + Scaffold complete E2E testing infrastructure for an existing game project. + Creates test fixtures, scenario builders, input simulators, and async + assertion utilities tailored to the project's architecture. + + triggers: + - "ES" + - "e2e-scaffold" + - "scaffold e2e" + - "e2e infrastructure" + - "setup e2e" + + preflight: + - "Test framework initialized (run `test-framework` workflow first)" + - "Game has identifiable state manager" + - "Main gameplay scene exists" + + # Paths are relative to this workflow file's location + knowledge_fragments: + - "../../../gametest/knowledge/e2e-testing.md" + - "../../../gametest/knowledge/unity-testing.md" + - "../../../gametest/knowledge/unreal-testing.md" + - "../../../gametest/knowledge/godot-testing.md" + + inputs: + game_state_class: + description: "Primary game state manager class name" + required: true + example: "GameStateManager" + + main_scene: + description: "Scene name where core gameplay occurs" + required: true + example: "GameScene" + + input_system: + description: "Input system in use" + required: false + default: "auto-detect" + options: + - "unity-input-system" + - "unity-legacy" + - "unreal-enhanced" + - "godot-input" + - "custom" + + # Output paths vary by engine. Generate files matching detected engine. + outputs: + unity: + condition: "engine == 'unity'" + infrastructure_files: + description: "Generated E2E infrastructure classes" + files: + - "Tests/PlayMode/E2E/Infrastructure/GameE2ETestFixture.cs" + - "Tests/PlayMode/E2E/Infrastructure/ScenarioBuilder.cs" + - "Tests/PlayMode/E2E/Infrastructure/InputSimulator.cs" + - "Tests/PlayMode/E2E/Infrastructure/AsyncAssert.cs" + assembly_definition: + description: "E2E test assembly configuration" + files: + - "Tests/PlayMode/E2E/E2E.asmdef" + example_test: + description: "Working example E2E test" + files: + - "Tests/PlayMode/E2E/ExampleE2ETest.cs" + documentation: + description: "E2E testing README" + files: + - "Tests/PlayMode/E2E/README.md" + + unreal: + condition: "engine == 'unreal'" + infrastructure_files: + description: "Generated E2E infrastructure classes" + files: + - "Source/{ProjectName}/Tests/E2E/GameE2ETestBase.h" + - "Source/{ProjectName}/Tests/E2E/GameE2ETestBase.cpp" + - "Source/{ProjectName}/Tests/E2E/ScenarioBuilder.h" + - "Source/{ProjectName}/Tests/E2E/ScenarioBuilder.cpp" + - "Source/{ProjectName}/Tests/E2E/InputSimulator.h" + - "Source/{ProjectName}/Tests/E2E/InputSimulator.cpp" + - "Source/{ProjectName}/Tests/E2E/AsyncAssert.h" + build_configuration: + description: "E2E test build configuration" + files: + - "Source/{ProjectName}/Tests/E2E/{ProjectName}E2ETests.Build.cs" + example_test: + description: "Working example E2E test" + files: + - "Source/{ProjectName}/Tests/E2E/ExampleE2ETest.cpp" + documentation: + description: "E2E testing README" + files: + - "Source/{ProjectName}/Tests/E2E/README.md" + + godot: + condition: "engine == 'godot'" + infrastructure_files: + description: "Generated E2E infrastructure classes" + files: + - "tests/e2e/infrastructure/game_e2e_test_fixture.gd" + - "tests/e2e/infrastructure/scenario_builder.gd" + - "tests/e2e/infrastructure/input_simulator.gd" + - "tests/e2e/infrastructure/async_assert.gd" + example_test: + description: "Working example E2E test" + files: + - "tests/e2e/scenarios/example_e2e_test.gd" + documentation: + description: "E2E testing README" + files: + - "tests/e2e/README.md" + + steps: + - id: analyze + name: "Analyze Game Architecture" + instruction_file: "instructions.md#step-1-analyze-game-architecture" + + - id: scaffold + name: "Generate Infrastructure" + instruction_file: "instructions.md#step-2-generate-infrastructure" + + - id: example + name: "Generate Example Test" + instruction_file: "instructions.md#step-3-generate-example-test" + + - id: document + name: "Generate Documentation" + instruction_file: "instructions.md#step-4-generate-documentation" + + - id: complete + name: "Output Summary" + instruction_file: "instructions.md#step-5-output-summary" + + validation: + checklist: "checklist.md" diff --git a/src/modules/bmgd/workflows/gametest/test-design/instructions.md b/src/modules/bmgd/workflows/gametest/test-design/instructions.md index b799dafe..96bf2869 100644 --- a/src/modules/bmgd/workflows/gametest/test-design/instructions.md +++ b/src/modules/bmgd/workflows/gametest/test-design/instructions.md @@ -91,6 +91,18 @@ Create comprehensive test scenarios for game projects, covering gameplay mechani | Performance | FPS, loading times | P1 | | Accessibility | Assist features | P1 | +### E2E Journey Testing + +**Knowledge Base Reference**: `knowledge/e2e-testing.md` + +| Category | Focus | Priority | +|----------|-------|----------| +| Core Loop | Complete gameplay cycle | P0 | +| Turn Lifecycle | Full turn from start to end | P0 | +| Save/Load Round-trip | Save → quit → load → resume | P0 | +| Scene Transitions | Menu → Game → Back | P1 | +| Win/Lose Paths | Victory and defeat conditions | P1 | + --- ## Step 3: Create Test Scenarios @@ -153,6 +165,39 @@ SCENARIO: Gameplay Under High Latency CATEGORY: multiplayer ``` +### E2E Scenario Format + +For player journey tests, use this extended format: +``` +E2E SCENARIO: [Player Journey Name] + GIVEN [Initial game state - use ScenarioBuilder terms] + WHEN [Sequence of player actions] + THEN [Observable outcomes] + TIMEOUT: [Expected max duration in seconds] + PRIORITY: P0/P1 + CATEGORY: e2e + INFRASTRUCTURE: [Required fixtures/builders] +``` + +### Example E2E Scenario +``` +E2E SCENARIO: Complete Combat Encounter + GIVEN game loaded with player unit adjacent to enemy + AND player unit has full health and actions + WHEN player selects unit + AND player clicks attack on enemy + AND player confirms attack + AND attack animation completes + AND enemy responds (if alive) + THEN enemy health is reduced OR enemy is defeated + AND turn state advances appropriately + AND UI reflects new state + TIMEOUT: 15 + PRIORITY: P0 + CATEGORY: e2e + INFRASTRUCTURE: ScenarioBuilder, InputSimulator, AsyncAssert +``` + --- ## Step 4: Prioritize Test Coverage @@ -161,12 +206,12 @@ SCENARIO: Gameplay Under High Latency **Knowledge Base Reference**: `knowledge/test-priorities.md` -| Priority | Criteria | Coverage Target | -| -------- | ---------------------------- | --------------- | -| P0 | Ship blockers, certification | 100% automated | -| P1 | Major features, common paths | 80% automated | -| P2 | Secondary features | 60% automated | -| P3 | Edge cases, polish | Manual only | +| Priority | Criteria | Unit | Integration | E2E | Manual | +|----------|----------|------|-------------|-----|--------| +| P0 | Ship blockers | 100% | 80% | Core flows | Smoke | +| P1 | Major features | 90% | 70% | Happy paths | Full | +| P2 | Secondary | 80% | 50% | - | Targeted | +| P3 | Edge cases | 60% | - | - | As needed | ### Risk-Based Ordering