diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index db06b3d0..67707d8d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,6 +34,12 @@ jobs: - name: Install dependencies run: npm install + - name: Run unit tests + run: npm run test:unit + + - name: Run integration tests + run: npm run test:integration + - name: Package tmax for e2e run: npm run package diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c2199d9..058ce499 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ For larger ideas, open an issue first to discuss the shape of the change before 2. Set up the dev environment (see [Building from Source](README.md#building-from-source)) and run `npm start` to verify the app launches. 3. Make your change. Keep PRs focused - one feature or fix per PR. 4. **Cross-platform compatibility is required.** tmax ships on Windows, macOS, and Linux. Use `isMac ? event.metaKey : event.ctrlKey` for primary modifiers, `formatKeyForPlatform()` for shortcut text in the UI, and avoid hardcoded paths or shell assumptions. See [`CLAUDE.md`](CLAUDE.md) for the full guidelines. -5. Run the e2e test suite: `npm run test:e2e`. Add a Playwright spec under `e2e/` for any user-visible bug fix or new feature. +5. Run the fast unit suite with `npm test`; run `npm run test:integration` for filesystem/git/CLI behavior; run the Electron e2e suite with `npm run test:e2e` for user-visible app behavior. Add unit tests under `tests/unit/` for pure logic, integration tests under `tests/integration/` for external boundaries, and Playwright specs under `tests/e2e/` for UI/Electron flows. 6. Open the PR with a description of what changed and why, plus any UI screenshots. ## Code style diff --git a/package-lock.json b/package-lock.json index a415b6c2..e9653c2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tmax", - "version": "1.9.3", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tmax", - "version": "1.9.3", + "version": "1.11.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -49,7 +49,8 @@ "electron": "^30.0.0", "fs-extra": "^11.3.3", "typescript": "^5.4.0", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^2.1.9" } }, "node_modules/@antfu/install-pkg": { @@ -3281,6 +3282,133 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vscode/sudo-prompt": { "version": "9.3.2", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", @@ -3712,6 +3840,16 @@ "node": ">=8.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", @@ -3968,6 +4106,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cacache": { "version": "16.1.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", @@ -4102,6 +4250,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4126,6 +4291,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chevrotain": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", @@ -5091,6 +5266,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -6093,6 +6278,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -6231,6 +6426,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -7737,6 +7942,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -7772,6 +7984,16 @@ "nan": "^2.4.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -8204,9 +8426,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -8713,6 +8935,16 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pe-library": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-1.0.1.tgz", @@ -8927,9 +9159,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -8947,7 +9179,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9750,6 +9982,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9967,6 +10206,20 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -10365,6 +10618,13 @@ "license": "MIT", "optional": true }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", @@ -10374,6 +10634,36 @@ "node": ">=18" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -10717,6 +11007,123 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -10883,6 +11290,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 64ba6e87..54a01545 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,11 @@ "start": "electron-forge start", "build": "electron-forge make", "package": "electron-forge package", + "test": "npm run test:unit", + "test:unit": "vitest run tests/unit", + "test:unit:watch": "vitest tests/unit", + "test:integration": "vitest run tests/integration", + "test:ci": "npm run test:unit && npm run test:integration && npm run package && npm run test:e2e", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report", @@ -56,7 +61,8 @@ "electron": "^30.0.0", "fs-extra": "^11.3.3", "typescript": "^5.4.0", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^2.1.9" }, "overrides": { "tar": "^7.5.11", diff --git a/tests/e2e/task-186-native-writes.spec.ts b/tests/integration/main/backlog-writer.integration.test.ts similarity index 93% rename from tests/e2e/task-186-native-writes.spec.ts rename to tests/integration/main/backlog-writer.integration.test.ts index d247c553..67ac9057 100644 --- a/tests/e2e/task-186-native-writes.spec.ts +++ b/tests/integration/main/backlog-writer.integration.test.ts @@ -1,19 +1,27 @@ -import { test, expect } from '@playwright/test'; +import { describe, test, expect } from 'vitest'; import { execFileSync } from 'child_process'; import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { initProject, createTask, editTask, archiveTask } from '../../src/main/backlog-writer'; +import { initProject, createTask, editTask, archiveTask } from '../../../src/main/backlog-writer'; // TASK-186: the native (no-CLI) write layer must produce files the real // backlog.md CLI can still read. These tests write with our code and read // back with the actual `backlog` binary to prove format compatibility. const isWin = process.platform === 'win32'; +const commandShell = process.env.ComSpec || 'cmd.exe'; + +function quoteCmdArg(arg: string): string { + return /[\s"]/u.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg; +} + function backlog(cwd: string, args: string[]): string { - // shell:true on win so the .cmd shim resolves; quote whitespace args. - const a = isWin ? args.map((x) => (/\s/.test(x) ? `"${x}"` : x)) : args; - return execFileSync('backlog', a, { cwd, encoding: 'utf-8', shell: isWin } as any); + if (isWin) { + const command = ['backlog', ...args.map(quoteCmdArg)].join(' '); + return execFileSync(commandShell, ['/d', '/s', '/c', command], { cwd, encoding: 'utf-8' }); + } + return execFileSync('backlog', args, { cwd, encoding: 'utf-8' }); } function gitInit(cwd: string) { execFileSync('git', ['init', '-q'], { cwd }); @@ -22,7 +30,15 @@ function gitInit(cwd: string) { } let cliAvailable = true; -try { execFileSync('backlog', ['--version'], { shell: isWin } as any); } catch { cliAvailable = false; } +try { + if (isWin) { + execFileSync(commandShell, ['/d', '/s', '/c', 'backlog --version'], { stdio: 'ignore' }); + } else { + execFileSync('backlog', ['--version'], { stdio: 'ignore' }); + } +} catch { + cliAvailable = false; +} // This one MUST run with no backlog CLI present - it proves a fresh user with // nothing installed can init + create + edit + archive entirely via tmax. @@ -60,7 +76,7 @@ test('full lifecycle works with no git and without calling the CLI (native only) // These exercise the native un-archive / find-anywhere / create-with-description // logic added this session. They never call the `backlog` CLI - all assertions // read the filesystem directly so they're hermetic and fast. -test.describe('native un-archive + find-anywhere (no CLI)', () => { +describe('native un-archive + find-anywhere (no CLI)', () => { const fs = require('fs'); const tasksDir = (d: string) => join(d, 'backlog', 'tasks'); const archiveDir = (d: string) => join(d, 'backlog', 'archive', 'tasks'); @@ -165,8 +181,7 @@ test.describe('native un-archive + find-anywhere (no CLI)', () => { }); }); -test.describe('native backlog writes are CLI-compatible', () => { - test.skip(!cliAvailable, 'backlog CLI not on PATH'); +describe.skipIf(!cliAvailable)('native backlog writes are CLI-compatible', () => { test('native init + create + edit + archive, verified by the real CLI', () => { const dir = mkdtempSync(join(tmpdir(), 'tmax-native-')); diff --git a/tests/unit/main/backlog-writer.test.ts b/tests/unit/main/backlog-writer.test.ts new file mode 100644 index 00000000..6afabf5e --- /dev/null +++ b/tests/unit/main/backlog-writer.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest'; +import { bareId } from '../../../src/main/backlog-writer'; + +describe('bareId', () => { + test('strips upper- and lower-case task prefixes', () => { + expect(bareId('TASK-123')).toBe('123'); + expect(bareId('task-456')).toBe('456'); + }); + + test('extracts ids from task filenames and plain strings', () => { + expect(bareId('task-42 - add-tests.md')).toBe('42'); + expect(bareId('789')).toBe('789'); + }); + + test('falls back to the input string when no numeric id exists', () => { + expect(bareId('draft')).toBe('draft'); + }); +}); diff --git a/tests/e2e/keybindings-file-parser.spec.ts b/tests/unit/main/keybindings-file-parser.test.ts similarity index 96% rename from tests/e2e/keybindings-file-parser.spec.ts rename to tests/unit/main/keybindings-file-parser.test.ts index 2fea44d8..9e0ca52d 100644 --- a/tests/e2e/keybindings-file-parser.spec.ts +++ b/tests/unit/main/keybindings-file-parser.test.ts @@ -1,12 +1,12 @@ -import { test, expect } from '@playwright/test'; -import { parseKeybindingsContent, serializeKeybindings } from '../../src/main/keybindings-file'; +import { describe, test, expect } from 'vitest'; +import { parseKeybindingsContent, serializeKeybindings } from '../../../src/main/keybindings-file'; // TASK-39: pure-function tests for the keybindings.json parser. The parser // must tolerate `//` line comments, trailing commas, and malformed entries // without aborting the whole file - one typo shouldn't lock the user out // of all their shortcuts. -test.describe('keybindings.json parser (TASK-39)', () => { +describe('keybindings.json parser (TASK-39)', () => { test('parses a clean array', () => { const out = parseKeybindingsContent(JSON.stringify([ { key: 'Ctrl+T', action: 'createTerminal' }, diff --git a/tests/e2e/pr57-path-traversal-guard.spec.ts b/tests/unit/main/pr57-path-traversal-guard.test.ts similarity index 90% rename from tests/e2e/pr57-path-traversal-guard.spec.ts rename to tests/unit/main/pr57-path-traversal-guard.test.ts index 547bc82a..03e0e21b 100644 --- a/tests/e2e/pr57-path-traversal-guard.spec.ts +++ b/tests/unit/main/pr57-path-traversal-guard.test.ts @@ -1,12 +1,12 @@ -import { test, expect } from '@playwright/test'; -import { assertNoPathTraversal, isPathWithinRoot } from '../../src/main/utils/security-guards'; +import { describe, test, expect } from 'vitest'; +import { assertNoPathTraversal, isPathWithinRoot } from '../../../src/main/utils/security-guards'; import * as path from 'path'; // Regression tests for PR #57 — path traversal guard in git-diff-service. // The guard was added to readFileContent and getAnnotatedFile to prevent // directory traversal attacks (e.g. ../../etc/passwd). -test.describe('isPathWithinRoot (PR #57)', () => { +describe('isPathWithinRoot (PR #57)', () => { test('allows root itself', () => { const root = path.resolve('/home/user/project'); expect(isPathWithinRoot(root, root)).toBe(true); @@ -37,7 +37,7 @@ test.describe('isPathWithinRoot (PR #57)', () => { }); }); -test.describe('assertNoPathTraversal (PR #57)', () => { +describe('assertNoPathTraversal (PR #57)', () => { // Use a real directory as root so path.resolve works correctly on Windows const root = process.cwd(); diff --git a/tests/e2e/pr58-open-path-guard.spec.ts b/tests/unit/main/pr58-open-path-guard.test.ts similarity index 92% rename from tests/e2e/pr58-open-path-guard.spec.ts rename to tests/unit/main/pr58-open-path-guard.test.ts index c375bb97..f193944f 100644 --- a/tests/e2e/pr58-open-path-guard.spec.ts +++ b/tests/unit/main/pr58-open-path-guard.test.ts @@ -1,10 +1,10 @@ -import { test, expect } from '@playwright/test'; -import { isDangerousExtension, DANGEROUS_OPEN_EXTENSIONS } from '../../src/main/utils/security-guards'; +import { describe, test, expect } from 'vitest'; +import { isDangerousExtension, DANGEROUS_OPEN_EXTENSIONS } from '../../../src/main/utils/security-guards'; // Regression tests for PR #58 — restrict shell.openPath to block // dangerous executable extensions. -test.describe('isDangerousExtension (PR #58)', () => { +describe('isDangerousExtension (PR #58)', () => { const dangerous = [ '.exe', '.bat', '.cmd', '.ps1', '.msi', '.com', '.scr', '.pif', '.lnk', '.hta', '.vbs', '.vbe', '.jse', '.wsf', '.wsh', diff --git a/tests/e2e/pr60-wsl-distro-validation.spec.ts b/tests/unit/main/pr60-wsl-distro-validation.test.ts similarity index 90% rename from tests/e2e/pr60-wsl-distro-validation.spec.ts rename to tests/unit/main/pr60-wsl-distro-validation.test.ts index 7f502e41..c33bf1f4 100644 --- a/tests/e2e/pr60-wsl-distro-validation.spec.ts +++ b/tests/unit/main/pr60-wsl-distro-validation.test.ts @@ -1,11 +1,11 @@ -import { test, expect } from '@playwright/test'; -import { isValidWslDistro } from '../../src/main/utils/security-guards'; +import { describe, test, expect } from 'vitest'; +import { isValidWslDistro } from '../../../src/main/utils/security-guards'; // Regression tests for PR #60 — confine FILE_READ/FILE_LIST by validating // WSL distro names. Invalid distro names could be used to escape the // intended path translation. -test.describe('isValidWslDistro (PR #60)', () => { +describe('isValidWslDistro (PR #60)', () => { test('accepts standard distro names', () => { expect(isValidWslDistro('Ubuntu')).toBe(true); expect(isValidWslDistro('Ubuntu-22.04')).toBe(true); diff --git a/tests/e2e/task-176-ai-session-parser-status.spec.ts b/tests/unit/main/task-176-ai-session-parser-status.test.ts similarity index 92% rename from tests/e2e/task-176-ai-session-parser-status.spec.ts rename to tests/unit/main/task-176-ai-session-parser-status.test.ts index 11a9d108..f86d1d6c 100644 --- a/tests/e2e/task-176-ai-session-parser-status.spec.ts +++ b/tests/unit/main/task-176-ai-session-parser-status.test.ts @@ -3,17 +3,16 @@ // // These are pure-function tests against the parsers; we write fake JSONL // files and assert that the parser-returned status reflects what the -// session is actually doing. Playwright's test runner is just being used -// for assertion sugar (matching the rest of the suite) - no Electron. -import { test, expect } from '@playwright/test'; +// session is actually doing - no Electron. +import { describe, test, expect } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { parseSessionEvents, clearParserCache, -} from '../../src/main/copilot-events-parser'; -import { parseClaudeCodeSession } from '../../src/main/claude-code-events-parser'; +} from '../../../src/main/copilot-events-parser'; +import { parseClaudeCodeSession } from '../../../src/main/claude-code-events-parser'; function writeJsonl(filePath: string, lines: Record[]): void { fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); @@ -23,7 +22,7 @@ function tmpJsonl(name: string): string { return path.join(os.tmpdir(), `tmax-test-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`); } -test.describe('Copilot CLI parser - pendingToolCalls + staleness (GH #118)', () => { +describe('Copilot CLI parser - pendingToolCalls + staleness (GH #118)', () => { test('assistant.turn_end with pending tools resets pendingToolCalls and unsticks executingTool', () => { const file = tmpJsonl('copilot-pending'); const now = new Date().toISOString(); @@ -105,7 +104,7 @@ test.describe('Copilot CLI parser - pendingToolCalls + staleness (GH #118)', () }); }); -test.describe('Claude Code parser - awaitingInput clear (GH #118)', () => { +describe('Claude Code parser - awaitingInput clear (GH #118)', () => { test('progress event after end_turn clears awaitingInput', () => { const file = tmpJsonl('claude-progress'); const now = new Date().toISOString(); diff --git a/tests/e2e/paste-wrap.spec.ts b/tests/unit/renderer/paste-wrap.test.ts similarity index 95% rename from tests/e2e/paste-wrap.spec.ts rename to tests/unit/renderer/paste-wrap.test.ts index af780cb6..03a16714 100644 --- a/tests/e2e/paste-wrap.spec.ts +++ b/tests/unit/renderer/paste-wrap.test.ts @@ -1,5 +1,5 @@ -import { test, expect } from '@playwright/test'; -import { prepareClipboardPaste } from '../../src/renderer/utils/paste'; +import { describe, test, expect } from 'vitest'; +import { prepareClipboardPaste } from '../../../src/renderer/utils/paste'; // Pure-function regression test for TASK-28. Earlier paste-related specs // (issue-72/73, detached-double-paste, double-paste mouse-reporting) only @@ -8,7 +8,7 @@ import { prepareClipboardPaste } from '../../src/renderer/utils/paste'; // reliably in offscreen e2e windows. By extracting the wrap into a pure // function we can lock the behaviour down without launching Electron. -test.describe('prepareClipboardPaste (TASK-28)', () => { +describe('prepareClipboardPaste (TASK-28)', () => { test('with bracketed paste enabled, payload is wrapped in CSI 200~ / 201~', () => { const out = prepareClipboardPaste('hello\nworld', true); expect(out).toBe('\x1b[200~hello\nworld\x1b[201~'); diff --git a/tests/e2e/smart-unwrap-on-copy.spec.ts b/tests/unit/renderer/smart-unwrap-on-copy.test.ts similarity index 92% rename from tests/e2e/smart-unwrap-on-copy.spec.ts rename to tests/unit/renderer/smart-unwrap-on-copy.test.ts index 65bb4fde..1b83feff 100644 --- a/tests/e2e/smart-unwrap-on-copy.spec.ts +++ b/tests/unit/renderer/smart-unwrap-on-copy.test.ts @@ -1,11 +1,10 @@ // TASK-52: smart unwrap on copy — unit tests for the heuristic. // -// Pure-function tests; do not launch Electron. Uses Playwright's test -// runner only for assertion sugar (the rest of the suite uses it too). -import { test, expect } from '@playwright/test'; -import { smartUnwrapForCopy } from '../../src/renderer/utils/smart-unwrap'; +// Pure-function tests; do not launch Electron. +import { describe, test, expect } from 'vitest'; +import { smartUnwrapForCopy } from '../../../src/renderer/utils/smart-unwrap'; -test.describe('smartUnwrapForCopy', () => { +describe('smartUnwrapForCopy', () => { test('disabled → returns input unchanged', () => { const input = 'a\n b\n c'; expect(smartUnwrapForCopy(input, false)).toBe(input); diff --git a/tests/e2e/task-180-prompt-composer.spec.ts b/tests/unit/renderer/task-180-prompt-composer.test.ts similarity index 87% rename from tests/e2e/task-180-prompt-composer.spec.ts rename to tests/unit/renderer/task-180-prompt-composer.test.ts index bd188fac..32d80caa 100644 --- a/tests/e2e/task-180-prompt-composer.spec.ts +++ b/tests/unit/renderer/task-180-prompt-composer.test.ts @@ -1,17 +1,16 @@ // TASK-180: prompt composer — pure-function tests for the draft map helpers // and the bracketed-paste payload Submit emits. // -// Pure tests; Electron is not launched. Mirrors the style of -// smart-unwrap-on-copy.spec.ts and paste-wrap.spec.ts. +// Pure tests; Electron is not launched. Mirrors the adjacent clipboard helpers. -import { test, expect } from '@playwright/test'; +import { describe, test, expect } from 'vitest'; import { dropComposerDraft, updateComposerDrafts, -} from '../../src/renderer/utils/prompt-composer'; -import { prepareClipboardPaste } from '../../src/renderer/utils/paste'; +} from '../../../src/renderer/utils/prompt-composer'; +import { prepareClipboardPaste } from '../../../src/renderer/utils/paste'; -test.describe('updateComposerDrafts', () => { +describe('updateComposerDrafts', () => { test('non-empty draft stores text under terminalId', () => { const out = updateComposerDrafts({}, 't1', 'hello'); expect(out).toEqual({ t1: 'hello' }); @@ -51,7 +50,7 @@ test.describe('updateComposerDrafts', () => { }); }); -test.describe('dropComposerDraft', () => { +describe('dropComposerDraft', () => { test('removes the entry for an existing terminalId', () => { const out = dropComposerDraft({ t1: 'hello', t2: 'world' }, 't1'); expect(out).toEqual({ t2: 'world' }); @@ -77,7 +76,7 @@ test.describe('dropComposerDraft', () => { }); }); -test.describe('Submit payload (bracketed paste + CRLF normalization)', () => { +describe('Submit payload (bracketed paste + CRLF normalization)', () => { // The composer's Submit handler delegates to prepareClipboardPaste with // bracketedPaste=true. These tests pin that contract from the composer's // perspective so a future refactor that drops the helper doesn't silently diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 00000000..460b7016 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'], + environment: 'node', + globals: false, + testTimeout: 5_000, + }, +});