diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3a5e51..5143f26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,10 +40,24 @@ jobs: run: npm run build --verbose timeout-minutes: 15 + # Runs every project's `test` script. The benchmark harness's `test` is deliberately its + # validate.js check and its unit tests, a few seconds, and NOT the benchmark itself, so this + # step stays fast on all four Node versions -- the karma arms run once, in the step below. + # `npm run test:full` at the repo root is the two combined, for anyone who wants everything in + # one command. - name: Unit tests run: node common/scripts/install-run-rush.js test --verbose timeout-minutes: 20 + # The benchmark harness is the only project that configures karma and ts-mocha, and karma 6's + # dependency tree does not tolerate every Node version in the matrix above, so it is measured on + # one version only. Its toolchain is pinned to exact versions -- see internal/benchmark's README, + # "Pinned toolchain". Changing the version here means changing that README too. + - name: Benchmark + if: matrix.node == 22 + run: npm run test:bench + timeout-minutes: 30 + - name: Docs link check run: npm run readme-link-check timeout-minutes: 10 @@ -65,3 +79,12 @@ jobs: files: ./shim/chai/coverage/coverage-final.json token: ${{ secrets.CODECOV_TOKEN }} flags: chai_shim + + - name: Upload Runner Coverage + if: matrix.node == 22 + uses: codecov/codecov-action@v5 + with: + slug: ${{ github.repository }} + files: ./internal/runner/coverage/coverage-final.json + token: ${{ secrets.CODECOV_TOKEN }} + flags: runner diff --git a/codecov.yml b/codecov.yml index 731fbe2..3988754 100644 --- a/codecov.yml +++ b/codecov.yml @@ -18,6 +18,7 @@ coverage: paths: - core/src - shim/chai/src + - internal/runner/src project: core: paths: @@ -31,6 +32,22 @@ coverage: target: 70% flags: - chai_shim + # runner/ was outside coverage measurement entirely until the runner flag was + # added. The target is a floor set just under the first measured value (32.4% + # lines) rather than an aspiration -- raise it as execute/, ux/, codecov/, + # compile/ and client/ gain tests, and do not lower it. + # + # The figure is low partly because it is honest: ux/browser and assets are + # browser-side code that the node test process cannot execute, and they are + # deliberately left in the denominator rather than excluded, so the number + # reflects what is actually untested rather than what is conveniently + # measurable. + runner: + paths: + - internal/runner/src + target: 30% + flags: + - runner flags: core: @@ -40,3 +57,7 @@ flags: chai_shim: paths: - shim/chai/src + + runner: + paths: + - internal/runner/src diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index e2e2651..26b54b5 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -485,9 +485,9 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.59.4", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.59.4.tgz", - "integrity": "sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig==", + "version": "7.60.0", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.60.0.tgz", + "integrity": "sha512-z744eKZNJ75h4WlsJiEa8oqbRUfczNe5MexweXe9kcm/phhZBZqfkDZCzHR5dDf8FdZvNapYK/wRPnSUAKuoug==", "dependencies": { "@microsoft/api-extractor-model": "7.33.15", "@microsoft/tsdoc": "~0.17.0", @@ -501,7 +501,7 @@ "resolve": "~1.22.1", "semver": "~7.7.4", "source-map": "~0.6.1", - "typescript": "5.9.3" + "typescript": "6.0.3" }, "bin": { "api-extractor": "bin/api-extractor" @@ -524,9 +524,9 @@ } }, "node_modules/@microsoft/api-extractor/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1003,9 +1003,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", - "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", "cpu": [ "arm" ], @@ -1015,9 +1015,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", - "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", "cpu": [ "arm64" ], @@ -1027,9 +1027,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", - "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", "cpu": [ "arm64" ], @@ -1039,9 +1039,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", - "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", "cpu": [ "x64" ], @@ -1051,9 +1051,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", - "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", "cpu": [ "arm64" ], @@ -1063,9 +1063,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", - "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", "cpu": [ "x64" ], @@ -1075,9 +1075,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", - "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", "cpu": [ "arm" ], @@ -1087,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", - "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", "cpu": [ "arm" ], @@ -1099,9 +1099,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", - "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", "cpu": [ "arm64" ], @@ -1111,9 +1111,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", - "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", "cpu": [ "arm64" ], @@ -1123,9 +1123,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", - "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", "cpu": [ "loong64" ], @@ -1135,9 +1135,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", - "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", "cpu": [ "loong64" ], @@ -1147,9 +1147,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", - "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", "cpu": [ "ppc64" ], @@ -1159,9 +1159,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", - "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", "cpu": [ "ppc64" ], @@ -1171,9 +1171,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", - "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", "cpu": [ "riscv64" ], @@ -1183,9 +1183,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", - "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", "cpu": [ "riscv64" ], @@ -1195,9 +1195,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", - "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", "cpu": [ "s390x" ], @@ -1219,9 +1219,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", - "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", "cpu": [ "x64" ], @@ -1231,9 +1231,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", - "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", "cpu": [ "x64" ], @@ -1243,9 +1243,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", - "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", "cpu": [ "arm64" ], @@ -1255,9 +1255,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", - "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", "cpu": [ "arm64" ], @@ -1267,9 +1267,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", - "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", "cpu": [ "ia32" ], @@ -1279,9 +1279,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", - "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", "cpu": [ "x64" ], @@ -1291,9 +1291,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", - "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", "cpu": [ "x64" ], @@ -2132,9 +2132,9 @@ } }, "node_modules/browserslist": { - "version": "4.29.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", - "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "funding": [ { "type": "opencollective", @@ -2150,10 +2150,10 @@ } ], "dependencies": { - "baseline-browser-mapping": "^2.11.29", - "caniuse-lite": "^1.0.30001816", - "electron-to-chromium": "^1.5.459", - "node-releases": "^2.0.59", + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": { @@ -2877,9 +2877,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.5.464", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", - "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==" + "version": "1.5.465", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.465.tgz", + "integrity": "sha512-zg4iUpbeGw7YNZsswc2xKr2uiBex2d8dELAXo92Icw3but1ULbrvK8USe6wVLaWjgJnKBARv0pchx9Owktx6wQ==" }, "node_modules/elliptic": { "version": "6.6.1", @@ -6231,9 +6231,9 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/rollup": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", - "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", "dependencies": { "@types/estree": "1.0.9" }, @@ -6246,31 +6246,31 @@ }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.65.0", - "@rollup/rollup-android-arm64": "4.65.0", - "@rollup/rollup-darwin-arm64": "4.65.0", - "@rollup/rollup-darwin-x64": "4.65.0", - "@rollup/rollup-freebsd-arm64": "4.65.0", - "@rollup/rollup-freebsd-x64": "4.65.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", - "@rollup/rollup-linux-arm-musleabihf": "4.65.0", - "@rollup/rollup-linux-arm64-gnu": "4.65.0", - "@rollup/rollup-linux-arm64-musl": "4.65.0", - "@rollup/rollup-linux-loong64-gnu": "4.65.0", - "@rollup/rollup-linux-loong64-musl": "4.65.0", - "@rollup/rollup-linux-ppc64-gnu": "4.65.0", - "@rollup/rollup-linux-ppc64-musl": "4.65.0", - "@rollup/rollup-linux-riscv64-gnu": "4.65.0", - "@rollup/rollup-linux-riscv64-musl": "4.65.0", - "@rollup/rollup-linux-s390x-gnu": "4.65.0", - "@rollup/rollup-linux-x64-gnu": "4.65.0", - "@rollup/rollup-linux-x64-musl": "4.65.0", - "@rollup/rollup-openbsd-x64": "4.65.0", - "@rollup/rollup-openharmony-arm64": "4.65.0", - "@rollup/rollup-win32-arm64-msvc": "4.65.0", - "@rollup/rollup-win32-ia32-msvc": "4.65.0", - "@rollup/rollup-win32-x64-gnu": "4.65.0", - "@rollup/rollup-win32-x64-msvc": "4.65.0", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", "fsevents": "~2.3.2" } }, @@ -6343,9 +6343,9 @@ "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==" }, "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.65.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", - "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", "cpu": [ "x64" ], diff --git a/core/package.json b/core/package.json index 0d9adfd..c950915 100644 --- a/core/package.json +++ b/core/package.json @@ -63,8 +63,8 @@ "test:nodesrc": "cross-env TRIPWIRE_PREFER_SOURCE=1 nyc --cache node ./bin/tripwire.js --type-check -p ./test/tsconfig.test.json \"./test/src/!(browser|worker)/**/*.test.ts\" -- --trace-uncaught && node ./bin/tripwire.js coverage-report", "test:browser": "node ./bin/tripwire.js --target browser --coverage --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|worker)/**/*.test.ts\"", "test:worker": "node ./bin/tripwire.js --target worker --coverage --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|browser)/**/*.test.ts\"", - "debug:browser": "node ./bin/tripwire.js --target browser --browser-launch open --source-maps --show-files --verbose --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|worker)/**/*.test.ts\"", - "debug:worker": "node ./bin/tripwire.js --target worker --browser-launch open --source-maps --show-files --verbose --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|browser)/**/*.test.ts\"", + "debug:browser": "node ./bin/tripwire.js --target browser --browser-launch open --source-maps --show-files --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|worker)/**/*.test.ts\"", + "debug:worker": "node ./bin/tripwire.js --target worker --browser-launch open --source-maps --show-files --type-check -p ./test/tsconfig.test.json \"./test/src/!(node|browser)/**/*.test.ts\"", "lint": "grunt tripwire-lint", "coverage:report": "npm run coverage:nyc && npm run coverage:merge", "coverage:nyc": "nyc report --reporter=json", diff --git a/core/rollup.config.js b/core/rollup.config.js index e41bb89..a862f93 100644 --- a/core/rollup.config.js +++ b/core/rollup.config.js @@ -179,6 +179,20 @@ const rollupConfigMainEntry = (srcPath, destPath, path, format = "umd") => { return taskRollupConfig; }; +// A circular import between this repository's own modules fails the build: which module's exports +// exist when another reads them then depends on which file happens to load first. A cycle entirely +// inside a third-party package is left as a warning, since it is not ours to fix. +function failOnCircularImport(warning, warn) { + if (warning.code === "CIRCULAR_DEPENDENCY") { + const ids = warning.ids || []; + if (!ids.some((id) => /[\\/]node_modules[\\/]/.test(id))) { + throw new Error("Circular import: " + (ids.length ? ids.join(" -> ") : warning.message)); + } + } + + warn(warning); +} + export default [ rollupConfigMainEntry("build/es5/mod", "dist/es5", "main", "umd"), rollupConfigMainEntry("build/es6/mod", "dist/es6", "main", "umd"), @@ -199,4 +213,4 @@ export default [ rollupConfigFactory("build/es6/mod", "bundle/es6", true, "iife", "iife"), rollupConfigFactory("build/es6/mod", "bundle/es6", false, "umd", "umd"), rollupConfigFactory("build/es6/mod", "bundle/es6", true, "umd", "umd") -]; +].map((config) => Object.assign(config, { onwarn: failOnCircularImport })); diff --git a/core/scripts/copyRunnerBin.js b/core/scripts/copyRunnerBin.js index b12c628..2210d2a 100644 --- a/core/scripts/copyRunnerBin.js +++ b/core/scripts/copyRunnerBin.js @@ -70,29 +70,56 @@ function copyRecursive(src, dest) { } } -function ensureLocalLinks() { - const coreDir = path.resolve(__dirname, ".."); - const nevware21Dir = path.join(coreDir, "node_modules", "@nevware21"); - const linkType = process.platform === "win32" ? "junction" : "dir"; +// Removes the link an older build put at `linkPath` -- only the link, never the folder it points to. +function _removeLink(linkPath) { + let stat = null; + try { + stat = fs.lstatSync(linkPath); + } catch (e) { + return; + } - if (!fs.existsSync(nevware21Dir)) { - fs.mkdirSync(nevware21Dir, { recursive: true }); + if (stat.isSymbolicLink()) { + // A Windows junction is reported as a link too, and unlink removes just the junction + fs.unlinkSync(linkPath); + console.log("\x1b[36mRemoved the old self-link: core/node_modules/@nevware21/tripwire\x1b[0m"); } +} - // Self-link so the copied runner binary can dynamically resolve "@nevware21/tripwire" - // (the package under test) when run against core's own dogfood test suite from within - // core/ -- the default UX/compile providers are bundled directly into the binary now - // (internal/runner/src/ux, internal/runner/src/compile), so no other local links are needed here. - const localLinks = { - "tripwire": coreDir +function ensureSelfPackage() { + const coreDir = path.resolve(__dirname, ".."); + const packageDir = path.join(coreDir, "node_modules", "@nevware21", "tripwire"); + + // Lets the copied runner binary, and core's own test files, resolve "@nevware21/tripwire" (the + // package under test) when core's dogfood test suite runs from within core/ -- the default + // UX/compile providers are bundled directly into the binary now (internal/runner/src/ux, + // internal/runner/src/compile), so nothing else needs resolving here. + // + // It is a folder holding just a package.json whose entry points lead back to core's build, and + // not a link to core itself: core contains this very folder, so such a link was a loop. Anything + // walking ignored folders through it -- `git clean -xdf` (the clean build), `git status --ignored` + // -- went round core/node_modules/@nevware21/tripwire/node_modules/... until the paths grew too + // long for the file system. Node resolves `main` to the real file under core/dist, so the module + // loaded is the same one either way. + _removeLink(packageDir); + fs.mkdirSync(packageDir, { recursive: true }); + + const corePackage = JSON.parse(fs.readFileSync(path.join(coreDir, "package.json"), "utf8")); + const redirect = { + name: corePackage.name, + version: corePackage.version, + private: true, + description: "Points back to this checkout's core build. Written by core/scripts/copyRunnerBin.js.", + main: "../../../" + corePackage.main, + module: "../../../" + corePackage.module, + types: "../../../" + corePackage.types }; + const text = JSON.stringify(redirect, null, 4) + "\n"; + const target = path.join(packageDir, "package.json"); - for (const [name, target] of Object.entries(localLinks)) { - const linkPath = path.join(nevware21Dir, name); - if (!fs.existsSync(linkPath)) { - fs.symlinkSync(target, linkPath, linkType); - console.log("\x1b[36mCreated local link: core/node_modules/@nevware21/" + name + "\x1b[0m"); - } + if (!fs.existsSync(target) || fs.readFileSync(target, "utf8") !== text) { + fs.writeFileSync(target, text, "utf8"); + console.log("\x1b[36mWrote local package: core/node_modules/@nevware21/tripwire\x1b[0m"); } } @@ -105,7 +132,7 @@ function main() { console.log("\x1b[36mCopying runner bin artifacts...\x1b[0m"); copyRecursive(srcDir, dstDir); - ensureLocalLinks(); + ensureSelfPackage(); console.log("\x1b[32mDone copying runner bin artifacts to core/bin\x1b[0m"); } diff --git a/core/test/src/browser/statusEndpoint.test.ts b/core/test/src/browser/statusEndpoint.test.ts index 3f71e54..66dd802 100644 --- a/core/test/src/browser/statusEndpoint.test.ts +++ b/core/test/src/browser/statusEndpoint.test.ts @@ -7,14 +7,20 @@ */ import { assert } from "../../../src/assert/assertClass"; -import { assertStatusResponse } from "../support/statusEndpoint"; +import { ITestContext } from "../../../src/interfaces/suite/ITestContext"; +import { assertStatusResponse, isServedByTripwire } from "../support/statusEndpoint"; describe("/api/status server endpoint", () => { - // it("returns running status from the browser server", async () => { - // const response = await fetch("/api/status"); - // const body = await response.json(); + it("returns running status from the browser server", async function (this: ITestContext) { + if (!isServedByTripwire()) { + // Another runner's page, with nothing serving /api/status (see isServedByTripwire) + this.skip(); + } - // assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); - // assertStatusResponse(response.status, response.headers.get("content-type"), body); - // }); + const response = await fetch("/api/status"); + const body = await response.json(); + + assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); + assertStatusResponse(response.status, response.headers.get("content-type"), body); + }); }); \ No newline at end of file diff --git a/core/test/src/support/statusEndpoint.ts b/core/test/src/support/statusEndpoint.ts index 842acc1..01d6081 100644 --- a/core/test/src/support/statusEndpoint.ts +++ b/core/test/src/support/statusEndpoint.ts @@ -8,6 +8,19 @@ import { assert } from "../../../src/assert/assertClass"; +/** + * Whether this page or worker was loaded by tripwire's own browser runner, whose server answers + * `/api/status` on the same origin. The browser and worker suites also run under other runners -- the + * benchmark's karma arms load these same folders -- where nothing answers that path, so the tests that + * fetch it skip there rather than fail. + * + * The marker is the module registry tripwire's runner installs before any test module loads: + * `__tripwireDefine__`, on the test frame's window (frame.html) or on the worker's global scope. + */ +export function isServedByTripwire(): boolean { + return typeof (globalThis as any).__tripwireDefine__ === "function"; +} + export function assertStatusResponse(statusCode: number, contentType: string | null, body: any): void { assert.equal(statusCode, 200, "Validate that /api/status returns HTTP 200"); assert.equal(contentType, "application/json; charset=utf-8", "Validate that /api/status returns JSON content"); diff --git a/core/test/src/worker/statusEndpoint.test.ts b/core/test/src/worker/statusEndpoint.test.ts index 678d9d6..0db409e 100644 --- a/core/test/src/worker/statusEndpoint.test.ts +++ b/core/test/src/worker/statusEndpoint.test.ts @@ -7,14 +7,20 @@ */ import { assert } from "../../../src/assert/assertClass"; -import { assertStatusResponse } from "../support/statusEndpoint"; +import { ITestContext } from "../../../src/interfaces/suite/ITestContext"; +import { assertStatusResponse, isServedByTripwire } from "../support/statusEndpoint"; describe("/api/status server endpoint", () => { - // it("returns running status from the worker server", async () => { - // const response = await fetch("/api/status"); - // const body = await response.json(); + it("returns running status from the worker server", async function (this: ITestContext) { + if (!isServedByTripwire()) { + // Another runner's worker, with nothing serving /api/status (see isServedByTripwire) + this.skip(); + } - // assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); - // assertStatusResponse(response.status, response.headers.get("content-type"), body); - // }); + const response = await fetch("/api/status"); + const body = await response.json(); + + assert.equal(typeof response.status, "number", "Validate that fetch returned a numeric status code"); + assertStatusResponse(response.status, response.headers.get("content-type"), body); + }); }); \ No newline at end of file diff --git a/internal/runner/rollup.config.js b/internal/runner/rollup.config.js index 85fab91..b079068 100644 --- a/internal/runner/rollup.config.js +++ b/internal/runner/rollup.config.js @@ -401,4 +401,18 @@ const mainBundle = { ] }; -module.exports = [clientBundle, workerBundle, mainBundle]; +// A circular import between this repository's own modules fails the build: which module's exports +// exist when another reads them then depends on which file happens to load first. A cycle entirely +// inside a third-party package is left as a warning, since it is not ours to fix. +function failOnCircularImport(warning, warn) { + if (warning.code === "CIRCULAR_DEPENDENCY") { + const ids = warning.ids || []; + if (!ids.some((id) => /[\\/]node_modules[\\/]/.test(id))) { + throw new Error("Circular import: " + (ids.length ? ids.join(" -> ") : warning.message)); + } + } + + warn(warning); +} + +module.exports = [clientBundle, workerBundle, mainBundle].map((config) => Object.assign(config, { onwarn: failOnCircularImport })); diff --git a/internal/runner/src/codecov/nycReport.ts b/internal/runner/src/codecov/nycReport.ts index d0baa29..c4fa5e3 100644 --- a/internal/runner/src/codecov/nycReport.ts +++ b/internal/runner/src/codecov/nycReport.ts @@ -7,6 +7,8 @@ */ import { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; /** * Builds one `--reporter ` pair per configured reporter (nyc's own convention for @@ -23,6 +25,33 @@ export function _buildReporterArgs(reporters?: string[]): string[] { return args; } +/** + * The project folder `nyc report` should work in: the nearest folder at or above `cwd` holding a + * package.json, or `cwd` itself when there is none -- the same folder nyc picks on its own. + * + * It is passed to nyc explicitly as `--cwd` because nyc otherwise starts that search from `NYC_CWD` + * when it is set, and an nyc run *above* this process sets it for every child (re-adding it to each + * spawn even if it is removed from the environment). The report would then resolve its paths and load + * its `.nycrc` include and exclude patterns from that outer project, and report none of this one's files. + */ +export function _nycCwd(cwd: string): string { + let dir = path.resolve(cwd); + + for (;;) { + // eslint-disable-next-line security/detect-non-literal-fs-filename + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir; + } + + const parent = path.dirname(dir); + if (parent === dir) { + return path.resolve(cwd); + } + + dir = parent; + } +} + /** * Shared `nyc report` invocation used by both the browser/worker coverage writer * ({@link NycCoverageReporter}) and the node-target {@link runCoverageReport} command -- @@ -42,7 +71,7 @@ export function _spawnNycReport(cwd: string, reportArgs: string[], label: string const result = spawnSync( process.execPath, - [nycPath, "report", ...reportArgs], + [nycPath, "report", "--cwd", _nycCwd(cwd), ...reportArgs], { cwd, stdio: "inherit" } ); diff --git a/internal/runner/src/compile/tsCompile.ts b/internal/runner/src/compile/tsCompile.ts index 433f524..7f139cc 100644 --- a/internal/runner/src/compile/tsCompile.ts +++ b/internal/runner/src/compile/tsCompile.ts @@ -157,7 +157,7 @@ export function getProjectFileNames(projectPath: string, cwd: string): string[] } const ts = loadTypescript(cwd); - const loadedConfig = ts.readConfigFile(fullProjectPath, ts.sys.readFile); + const loadedConfig = _readTsConfig(ts, fullProjectPath); if (loadedConfig.error) { return null; } @@ -200,6 +200,15 @@ function _normCasePath(filePath: string, caseSensitive: boolean): string { return caseSensitive ? normalized : normalized.toLowerCase(); } +/** + * Reads a tsconfig, passing its path with forward slashes the way TypeScript keeps its own file + * names. Given a Windows path with backslashes, a syntax error in the config fails TypeScript's + * internal path assertion ("Debug Failure") rather than coming back as the config's `error`. + */ +function _readTsConfig(ts: any, configPath: string): any { + return ts.readConfigFile(_normCasePath(configPath, true), ts.sys.readFile); +} + /** * Per-process cache of TypeScript programs keyed by ":". * Creating a TypeScript program is expensive (it parses all project files). By caching the @@ -320,7 +329,7 @@ function _getOrCreateTsProgramEntry(ts: any, configPath: string, includeSourceMa return _tsProgCache[cacheKey]; } - const loadedConfig = ts.readConfigFile(configPath, ts.sys.readFile); + const loadedConfig = _readTsConfig(ts, configPath); if (loadedConfig.error) { return null; } @@ -773,7 +782,7 @@ export function registerTypeScriptHook(projectPath: string, cwd: string, checkDi // eslint-disable-next-line security/detect-non-literal-fs-filename if (fs.existsSync(fullProjectPath)) { - const loadedConfig = ts.readConfigFile(fullProjectPath, ts.sys.readFile); + const loadedConfig = _readTsConfig(ts, fullProjectPath); if (!loadedConfig.error) { const parsed = ts.parseJsonConfigFileContent(loadedConfig.config, ts.sys, path.dirname(fullProjectPath)); if (!parsed.errors || !parsed.errors.length) { diff --git a/internal/runner/src/execute/browser.ts b/internal/runner/src/execute/browser.ts index fc711de..bcc76a3 100644 --- a/internal/runner/src/execute/browser.ts +++ b/internal/runner/src/execute/browser.ts @@ -99,6 +99,15 @@ export async function _runInteractiveBrowserTarget(target: string, graphBuilders } } +function _isFile(filePath: string): boolean { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + return fs.statSync(filePath).isFile(); + } catch (e) { + return false; + } +} + export function _resolveBrowserDependency(spec: string, moduleId: string, cwd: string): string { try { return require.resolve(spec, { @@ -120,8 +129,9 @@ export function _resolveBrowserDependency(spec: string, moduleId: string, cwd: s ]; for (let lp = 0; lp < candidates.length; lp++) { - // eslint-disable-next-line security/detect-non-literal-fs-filename - if (fs.existsSync(candidates[lp])) { + // A file, not just a path that exists: the bare path of a folder import is the + // folder itself, which has to fall through to its index file. + if (_isFile(candidates[lp])) { return candidates[lp]; } } diff --git a/internal/runner/src/execute/browserRunner.ts b/internal/runner/src/execute/browserRunner.ts index a2dd516..a917520 100644 --- a/internal/runner/src/execute/browserRunner.ts +++ b/internal/runner/src/execute/browserRunner.ts @@ -6,7 +6,7 @@ * Licensed under the MIT license. */ -import path from "path/win32"; +import * as path from "path"; import { _loadUxProvider } from "../client/loadUxProvider"; import { IServerContext } from "../interfaces/IServerContext"; import { logApiHandler } from "../server/handlers/api/logMsg"; diff --git a/internal/runner/src/execute/puppeteer.ts b/internal/runner/src/execute/puppeteer.ts index f5b9f66..81f004f 100644 --- a/internal/runner/src/execute/puppeteer.ts +++ b/internal/runner/src/execute/puppeteer.ts @@ -175,6 +175,15 @@ export async function _runPuppeteerOnce(session: IBrowserRunnerSession, target: // Armed *before* navigating -- the page's own "ready" -> run -> report round trip can only // ever report back to whichever completion is currently live. const completion = session.nextCompletion(); + + // Nothing awaits this until the browser is up and `page.goto` has returned. A page that fails + // fast can report its error inside that window, and the rejection was then an + // unhandledRejection, killing the process with a raw stack. Marking it handled changes nothing + // about the error: the await below still sees the same rejection and reports it the normal way. + completion.catch(() => { + // Reported by the await below. + }); + const browser = await (browserPromise || _launchPuppeteerBrowser(process.cwd())); try { diff --git a/internal/runner/src/execute/targetSessionCache.ts b/internal/runner/src/execute/targetSessionCache.ts index 86bcebb..f692f28 100644 --- a/internal/runner/src/execute/targetSessionCache.ts +++ b/internal/runner/src/execute/targetSessionCache.ts @@ -52,6 +52,30 @@ export interface ITargetSessionCacheOptions { watch: boolean; } +/** + * The spelling of `filePath` to watch it by: absolute, with the platform's separators, every 8.3 + * short name expanded and any link resolved -- the file system's own name for it. On Windows the + * libuv in Node 24 aborts the whole process (an assertion in src/win/fs-event.c) when a change is + * reported under a watched directory spelled any other way, as with forward slashes (the browser + * and worker module graph's ids) or a short name (a temp folder such as C:\Users\BUILDA~1\...). + * A file that no longer exists keeps its own name, in its resolved directory if that still exists. + */ +export function _watchPath(filePath: string): string { + const resolved = path.resolve(filePath); + + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + return fs.realpathSync.native(resolved); + } catch (e) { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + return path.join(fs.realpathSync.native(path.dirname(resolved)), path.basename(resolved)); + } catch (e2) { + return resolved; + } + } +} + /** * Watches `filePaths` for changes and calls `onChange` (debounced ~200ms, so a single save * that fires multiple fs events only triggers one call) until `close()` is called. Watches each @@ -97,8 +121,9 @@ export function watchFiles(filePaths: string[], onChange: () => void): { close() const filesByDir: Record = {}; filePaths.forEach((filePath) => { - const dir = path.dirname(filePath); - (filesByDir[dir] = filesByDir[dir] || []).push(path.basename(filePath)); + const watched = _watchPath(filePath); + const dir = path.dirname(watched); + (filesByDir[dir] = filesByDir[dir] || []).push(path.basename(watched)); }); try { diff --git a/internal/runner/test/src/cli/cliFromSource.test.ts b/internal/runner/test/src/cli/cliFromSource.test.ts new file mode 100644 index 0000000..109abcf --- /dev/null +++ b/internal/runner/test/src/cli/cliFromSource.test.ts @@ -0,0 +1,549 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { execFile } from "child_process"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; + +const RUNNER_DIR = path.resolve(__dirname, "../../.."); +const LAUNCHER = path.resolve(__dirname, "../support/cliFromSource.js"); + +const TS_CONFIG = { + compilerOptions: { target: "es2018", module: "commonjs", esModuleInterop: true, skipLibCheck: true, types: [] as string[], lib: [ "es2018" ] }, + include: [ "test/**/*.ts" ] +}; + +// A stand-in for puppeteer, installed in the browser fixture's node_modules: its one page reports a run +// to the server it is pointed at, passing for the browser target and failing for the worker target. +// Any other page (the CLI's warm-up) fails to load. +const FAKE_PUPPETEER = [ + "const http = require(\"http\");", + "function report(url) {", + " const target = new URL(url);", + " const failed = target.pathname.indexOf(\"/worker\") !== -1 ? 1 : 0;", + " const body = JSON.stringify({ summary: { total: 1, passed: 1 - failed, failed: failed, skipped: 0, pending: 0, duration: 1 } });", + " return new Promise((resolve, reject) => {", + " const req = http.request({ host: target.hostname, port: target.port, method: \"POST\", path: \"/api/result\", agent: false,", + " headers: { \"content-type\": \"application/json\", \"content-length\": Buffer.byteLength(body) } }, (res) => {", + " res.resume();", + " res.on(\"end\", resolve);", + " });", + " req.on(\"error\", reject);", + " req.end(body);", + " });", + "}", + "exports.launch = async function () {", + " const page = {", + " on: function () {},", + " goto: async function (url) {", + " if (url.indexOf(\"/__tripwire__/\") === -1) {", + " throw new Error(\"nothing is listening\");", + " }", + " await report(url);", + " }", + " };", + " return { pages: async () => [ page ], newPage: async () => page, close: async () => {} };", + "};", + "" +].join("\n"); + +const BROWSER_TEST = "import { describe, it } from \"@nevware21/tripwire\";\ndescribe(\"browser\", () => {\n it(\"runs\", () => {\n });\n});\n"; + +const BROWSER_FILES: { [file: string]: string } = { + "package.json": JSON.stringify({ name: "@nevware21/tripwire", version: "0.0.0" }), + "src/index.ts": "export function describe(name: string, fn: () => void): void {\n fn();\n}\nexport function it(name: string, fn: () => void): void {\n fn();\n}\n", + "test/a.test.ts": BROWSER_TEST, + "test/setup.ts": "export const setup = true;\n", + "node_modules/puppeteer/package.json": JSON.stringify({ name: "puppeteer", version: "0.0.0", main: "index.js" }), + "node_modules/puppeteer/index.js": FAKE_PUPPETEER +}; + +const BROWSER_TS_CONFIG = { compilerOptions: TS_CONFIG.compilerOptions, include: [ "src/**/*.ts", "test/**/*.ts" ] }; + +const BROKEN = "export const broken: number = \"not a number\";\n"; + +interface IInvocation { + args: string[]; + exitCode: number; + out: string[]; + err: string[]; + spawned: string[][]; +} + +/** + * The tripwire CLI run end to end against small throwaway projects, loaded from the runner's sources so + * a coverage run measures them (the rest of this suite runs under the bundled CLI). Every invocation + * runs in one child process, one after another; each test below then checks what one of them printed + * and the exit code it left. + */ +describe("tripwire cli, run from source", function () { + // One child process compiles the runner and runs every invocation + this.timeout(300000); + + let scratch: string; + let projects: { [name: string]: string }; + let results: { [name: string]: IInvocation }; + + function write(dir: string, name: string, text: string): string { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + // A project whose node_modules links back to this repo's tripwire, as an installed one would -- + // unless `noTripwire` leaves it out + function makeProject(name: string, files: { [file: string]: string }, tsconfig?: any, noTripwire?: boolean): string { + const dir = path.join(scratch, name); + fs.mkdirSync(path.join(dir, "node_modules", "@nevware21"), { recursive: true }); + if (!noTripwire) { + fs.symlinkSync(path.dirname(require.resolve("@nevware21/tripwire/package.json")), path.join(dir, "node_modules", "@nevware21", "tripwire"), "junction"); + } + + write(dir, "tsconfig.json", JSON.stringify(tsconfig || TS_CONFIG)); + Object.keys(files).forEach((file) => write(dir, file, files[file])); + + return dir; + } + + function run(name: string): IInvocation { + const result = results[name]; + assert.isObject(result, "the '" + name + "' invocation ran"); + + return result; + } + + function out(name: string): string { + return replaceAnsi(run(name).out.join("\n"), ""); + } + + function err(name: string): string { + return replaceAnsi(run(name).err.join("\n"), ""); + } + + before(async () => { + // The real path, as the CLI prints paths from its working directory (on macOS the temp folder is a link) + scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cli-source-"))); + + const passing = [ + "import { describe, it, assert } from \"@nevware21/tripwire\";", + "describe(\"passing\", () => {", + " it(\"adds\", () => {", + " assert.equal(1 + 1, 2);", + " });", + " it(\"joins\", () => {", + " assert.equal([ \"a\", \"b\" ].join(\"\"), \"ab\");", + " });", + "});", + "" + ].join("\n"); + + projects = { + pass: makeProject("pass", { + "test/pass.test.ts": passing, + "setup.js": "console.log(\"setup loaded\");\n" + }), + fail: makeProject("fail", { + "test/fail.test.ts": [ + "import { describe, it, assert } from \"@nevware21/tripwire\";", + "describe(\"failing\", () => {", + " it(\"fails\", () => {", + " assert.equal(1, 2);", + " });", + " it(\"is never reached with --bail\", () => {", + " assert.ok(true);", + " });", + "});", + "" + ].join("\n") + }), + hooks: makeProject("hooks", { + // A failing `after` hook belongs to no test, so it reaches no pass/fail count + "test/hook.test.ts": [ + "import { describe, it, after } from \"@nevware21/tripwire\";", + "describe(\"hooked\", () => {", + " after(() => {", + " throw new Error(\"teardown broke\");", + " });", + " it(\"passes\", () => {", + " });", + "});", + "" + ].join("\n") + }), + typeError: makeProject("typeError", { + "test/bad.test.ts": "import { it } from \"@nevware21/tripwire\";\nconst count: number = \"not a number\";\nit(\"never runs\", () => count);\n" + }), + scope: makeProject("scope", { + "test/a.test.ts": passing, + "test/b.test.ts": passing.replace("\"passing\"", "\"also passing\"") + }, { compilerOptions: TS_CONFIG.compilerOptions, files: [ "test/a.test.ts" ] }), + loadError: makeProject("loadError", { + "test/load.test.ts": "throw new Error(\"broken at load\");\n" + }), + coverage: makeProject("coverage", { + "package.json": "{}", + ".tripwirerc.json": JSON.stringify({ erasedCoverageMode: "zero" }) + }), + // Named @nevware21/tripwire, as core's own suite is, so the browser module graph uses this + // project's small src/index.ts as the tripwire runtime instead of compiling all of core + browser: makeProject("browser", BROWSER_FILES, BROWSER_TS_CONFIG), + empty: makeProject("empty", { "test/empty.test.ts": "export const nothing = true;\n" }), + // Nothing installed resolves @nevware21/tripwire, so the CLI falls back to tripwire's sources + browserNoTripwire: makeProject("browserNoTripwire", BROWSER_FILES, BROWSER_TS_CONFIG, true), + // The watch runs are left waiting on their files, so each has a project of its own. The + // browser one starts with a type error, so its first round has built nothing to watch. + watchNode: makeProject("watchNode", { "test/watch.test.ts": passing }), + watchBrowser: makeProject("watchBrowser", Object.assign({}, BROWSER_FILES, { "test/a.test.ts": BROWSER_TEST + BROKEN }), BROWSER_TS_CONFIG) + }; + + // nyc output for the coverage-report command: one file, half covered + const covered = path.join(projects.coverage, "src", "a.js"); + const data: any = {}; + data[covered] = { + path: covered, + statementMap: { + 0: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } }, + 1: { start: { line: 2, column: 0 }, end: { line: 2, column: 10 } } + }, + fnMap: {}, + branchMap: {}, + s: { 0: 1, 1: 0 }, + f: {}, + b: {} + }; + write(projects.coverage, "raw/out.json", JSON.stringify(data)); + + const manualReport = { summary: { total: 2, passed: 2, failed: 0, skipped: 0, pending: 0, duration: 1 } }; + const plan: Array<{ name: string, cwd: string, args: string[], report?: any, watch?: any, tty?: boolean, stubSpawn?: boolean, env?: any }> = [ + { name: "help", cwd: projects.pass, args: [ "--help" ] }, + { name: "badOption", cwd: projects.pass, args: [ "--no-such-option" ] }, + { name: "pass", cwd: projects.pass, args: [ "test/**/*.test.ts", "--type-check", "--show-files", "--require", "./setup.js" ] }, + { name: "passAgain", cwd: projects.pass, args: [ "test/**/*.test.ts", "--type-check" ] }, + { name: "isolated", cwd: projects.pass, args: [ "test/**/*.test.ts", "--isolated-transpile", "--reporter", "dot" ] }, + { name: "noGrepMatch", cwd: projects.pass, args: [ "test/**/*.test.ts", "--grep", "nothing-matches-this" ] }, + { name: "noFiles", cwd: projects.pass, args: [ "nothing/**/*.test.ts", "--ignore", "**/skip/**" ] }, + { name: "server", cwd: projects.pass, args: [ "test/**/*.test.ts", "--server", "--bind-address", "127.0.0.1" ] }, + { name: "browserReporter", cwd: projects.pass, args: [ "test/**/*.test.ts", "--target", "browser", "--reporter", "./my-reporter.js" ] }, + { name: "fail", cwd: projects.fail, args: [ "test/**/*.test.ts", "--bail" ] }, + { name: "hookWarn", cwd: projects.hooks, args: [ "test/**/*.test.ts" ] }, + { name: "hookFail", cwd: projects.hooks, args: [ "test/**/*.test.ts", "--fail-on-hook" ] }, + { name: "typeError", cwd: projects.typeError, args: [ "test/**/*.test.ts", "--type-check" ] }, + { name: "scopeWarn", cwd: projects.scope, args: [ "test/**/*.test.ts", "--type-check" ] }, + { name: "scopeStrict", cwd: projects.scope, args: [ "test/**/*.test.ts", "--strict-type-check" ] }, + { name: "loadError", cwd: projects.loadError, args: [ "test/**/*.test.ts" ] }, + { name: "coverageReport", cwd: projects.coverage, args: [ "coverage-report", "--nyc-output-dir", "raw", "--coverage-reporter", "json-summary", "--erased-coverage-mode=full" ] }, + { name: "coverageReportBadMode", cwd: projects.coverage, args: [ "coverage-report", "--erased-coverage-mode", "bogus" ] }, + { name: "browserPuppeteer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--type-check", "--require", "./test/setup.ts" ] }, + { name: "workerPuppeteer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "worker" ] }, + { name: "browserGraphThread", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--graph-thread" ] }, + { name: "browserManual", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--browser-launch", "manual" ], report: manualReport }, + { name: "browserNoServer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--no-server" ] }, + { name: "browserPreloadPackage", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--require", "some-package" ] }, + { name: "workerPreloadMissing", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "worker", "--require", "./missing-setup.ts" ] }, + { name: "browserNoFiles", cwd: projects.browser, args: [ "nothing/**/*.test.ts", "--target", "browser" ] }, + { name: "browserTty", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser" ], tty: true }, + { + name: "browserOpen", + cwd: projects.browser, + args: [ "test/**/*.test.ts", "--target", "worker", "--browser-launch", "open", "--no-server" ], + report: manualReport, + stubSpawn: true + }, + { name: "watchBrowserNoServer", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser", "--watch", "--no-server" ] }, + { name: "watchBrowserNoFiles", cwd: projects.browser, args: [ "nothing/**/*.test.ts", "--target", "browser", "--watch" ] }, + { name: "emptyRun", cwd: projects.empty, args: [ "test/**/*.test.ts" ] }, + { name: "noProject", cwd: projects.pass, args: [ "test/**/*.test.ts", "--type-check", "-p", "missing.json", "--slow", "50" ] }, + { name: "nodeTty", cwd: projects.pass, args: [ "test/**/*.test.ts" ], tty: true }, + // The browser target does not run tests with the runtime the CLI loads, so these can load any + { name: "preferSource", cwd: projects.browser, args: [ "test/**/*.test.ts", "--target", "browser" ], env: { TRIPWIRE_PREFER_SOURCE: "1" } }, + { name: "noTripwireInstalled", cwd: projects.browserNoTripwire, args: [ "test/**/*.test.ts", "--target", "browser" ] }, + + // Last, as the launcher leaves each of these waiting on its files once it has gone round + { + name: "watchNode", + cwd: projects.watchNode, + args: [ "test/**/*.test.ts", "--watch" ], + watch: { edits: [ { file: "test/watch.test.ts", text: passing.replace("describe(\"passing\", () => {", "describe(\"passing\", () => {\n it(\"was added\", () => {\n });") } ] } + }, + { + name: "watchBrowser", + cwd: projects.watchBrowser, + args: [ "test/**/*.test.ts", "--target", "browser", "--watch", "--type-check", "--require", "./test/setup.ts" ], + watch: { + edits: [ + { file: "test/a.test.ts", text: BROWSER_TEST + "export const fixed: number = 1;\n" }, + { file: "test/a.test.ts", text: BROWSER_TEST + BROKEN } + ] + } + } + ]; + + const planFile = write(scratch, "plan.json", JSON.stringify(plan)); + const resultsFile = path.join(scratch, "results.json"); + + await new Promise((resolve, reject) => { + execFile(process.execPath, [ LAUNCHER, RUNNER_DIR, planFile, resultsFile ], { cwd: scratch, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error || !fs.existsSync(resultsFile)) { + reject(new Error("The CLI launcher failed: " + (error ? error.message : "no results") + "\n" + stdout + "\n" + stderr)); + return; + } + + resolve(); + }); + }); + + const ran: IInvocation[] = JSON.parse(fs.readFileSync(resultsFile, "utf8")); + results = {}; + plan.forEach((step, idx) => { + results[step.name] = ran[idx]; + }); + }); + + after(() => { + if (scratch) { + fs.rmSync(scratch, { recursive: true, force: true }); + } + }); + + describe("usage", () => { + it("prints the usage for --help", () => { + assert.equal(run("help").exitCode, 0); + assert.includes(out("help"), "tripwire [options] "); + assert.includes(out("help"), "coverage-report [--erased-coverage-mode ]"); + }); + + it("rejects an unknown option with the usage", () => { + assert.equal(run("badOption").exitCode, 1); + assert.includes(err("badOption"), "--no-such-option"); + assert.includes(out("badOption"), "tripwire [options] "); + }); + }); + + describe("node target", () => { + it("type-checks, runs and summarises a passing project", () => { + const text = out("pass"); + + assert.equal(run("pass").exitCode, 0, err("pass")); + assert.includes(text, "setup loaded"); + assert.includes(text, "Matched 1 file(s)"); + assert.includes(text, " - test/pass.test.ts"); + assert.includes(text, "Type-checking TypeScript project: " + path.join(projects.pass, "tsconfig.json")); + assert.includes(text, "Type-check completed in "); + assert.includes(text, "Compile cache: rebuilt"); + assert.includes(text, "Run summary: total=2 passed=2 failed=0"); + }); + + it("reuses the compile cache, skipping a clean type-check, when nothing changed", () => { + assert.equal(run("passAgain").exitCode, 0, err("passAgain")); + assert.includes(out("passAgain"), "Compile cache: reused -- nothing changed, type-check skipped"); + assert.includes(out("passAgain"), "Run summary: total=2 passed=2"); + }); + + it("compiles each file on its own when asked, with the chosen reporter", () => { + assert.equal(run("isolated").exitCode, 0, err("isolated")); + assert.includes(out("isolated"), "Compile cache: isolated"); + assert.includes(out("isolated"), "Run summary: total=2 passed=2"); + }); + + it("fails a run whose --grep matches nothing", () => { + assert.equal(run("noGrepMatch").exitCode, 1); + assert.includes(err("noGrepMatch"), "No tests matched --grep nothing-matches-this. Check the pattern, or drop it to run the full suite."); + }); + + it("reports patterns that match no files as a user error", () => { + assert.equal(run("noFiles").exitCode, 1); + assert.includes(err("noFiles"), "No files matched: nothing/**/*.test.ts"); + assert.includes(err("noFiles"), "Files matching --ignore were excluded: **/skip/**"); + }); + + it("runs with the embedded server when asked", () => { + assert.equal(run("server").exitCode, 0, err("server")); + assert.includes(out("server"), "Run summary: total=2 passed=2"); + }); + + it("fails a run with a failing test, stopping at the first with --bail", () => { + assert.equal(run("fail").exitCode, 1); + assert.includes(out("fail"), "failed=1"); + assert.isFalse(out("fail").indexOf("passed=1") !== -1, "the test after the failure did not pass"); + }); + + it("warns about a failing hook, and fails the run on one with --fail-on-hook", () => { + assert.equal(run("hookWarn").exitCode, 0, err("hookWarn")); + assert.includes(out("hookWarn"), "1 hook failed during this run -- run with --fail-on-hook to fail the run on them."); + + assert.equal(run("hookFail").exitCode, 1); + assert.includes(out("hookFail"), "Hook failures (1 hook):"); + assert.includes(err("hookFail"), "teardown broke"); + assert.includes(err("hookFail"), "Run failed: 1 hook failed (--fail-on-hook)."); + }); + + it("fails the run on a type error with --type-check", () => { + assert.equal(run("typeError").exitCode, 1); + assert.includes(err("typeError"), "TypeScript compile failed with 1 error(s)"); + assert.includes(err("typeError"), "TS2322"); + }); + + it("warns when matched files are outside the type-checked project, and fails with --strict-type-check", () => { + assert.equal(run("scopeWarn").exitCode, 0, err("scopeWarn")); + assert.includes(err("scopeWarn"), "Warning: "); + assert.includes(err("scopeWarn"), "b.test.ts"); + + assert.equal(run("scopeStrict").exitCode, 1); + assert.includes(err("scopeStrict"), "Type-check scope: "); + }); + + it("re-runs with --watch when a test file changes", () => { + const text = out("watchNode"); + + assert.includes(text, "Run summary: total=2 passed=2"); + assert.includes(text, "Run summary: total=3 passed=3", "the edited file was loaded again"); + assert.equal(text.split("Watching ").length - 1, 2); + assert.includes(text, "for changes... (Ctrl+C to stop)"); + }); + + it("fails a run whose files register no tests", () => { + assert.equal(run("emptyRun").exitCode, 1); + assert.includes(err("emptyRun"), "No tests were executed. Verify imports resolve to the same tripwire runtime and that your files register tests."); + }); + + it("warns when the project to type-check against cannot be read", () => { + assert.equal(run("noProject").exitCode, 0, err("noProject")); + assert.includes(err("noProject"), "the type-check covered no files: missing.json could not be read."); + assert.includes(err("noProject"), "Use --strict-type-check to fail the run instead of warning."); + assert.includes(out("noProject"), "Run summary: total=2 passed=2"); + }); + + it("runs with the reporters writing to a terminal", () => { + assert.equal(run("nodeTty").exitCode, 0, err("nodeTty")); + assert.includes(out("nodeTty"), "Run summary: total=2 passed=2"); + }); + + it("reports a test file that throws while loading", () => { + assert.equal(run("loadError").exitCode, 1); + assert.includes(err("loadError"), "broken at load"); + }); + }); + + describe("browser and worker targets", () => { + it("type-checks, builds and runs the browser target through puppeteer", () => { + const text = out("browserPuppeteer"); + + assert.equal(run("browserPuppeteer").exitCode, 0, err("browserPuppeteer")); + assert.includes(text, "Matched 2 file(s)"); + assert.includes(text, "Type-checking TypeScript project: " + path.join(projects.browser, "tsconfig.json")); + assert.includes(text, "Launching browser target via Puppeteer..."); + assert.includes(text, "Module graph: "); + assert.includes(text, "Ready to run tests after "); + assert.includes(text, "Run summary: total=1 passed=1 failed=0"); + }); + + it("fails a worker run whose page reports a failure", () => { + assert.equal(run("workerPuppeteer").exitCode, 1); + assert.includes(out("workerPuppeteer"), "Launching worker target via Puppeteer..."); + assert.includes(out("workerPuppeteer"), "failed=1"); + }); + + it("builds the graph on the main thread when the graph thread cannot", () => { + assert.equal(run("browserGraphThread").exitCode, 0, err("browserGraphThread")); + assert.includes(out("browserGraphThread"), "Run summary: total=1 passed=1"); + }); + + it("serves a manual launch until the page reports its results", () => { + const text = out("browserManual"); + + assert.equal(run("browserManual").exitCode, 0, err("browserManual")); + assert.includes(text, "Launching browser target via browser UI (mode: manual, style: classic)..."); + assert.includes(text, "Ready after "); + assert.includes(text, "Open this URL in your browser to start the run: http://127.0.0.1:"); + assert.includes(text, "Run summary: total=2 passed=2"); + }); + + it("rejects running without the server, a package preload, a missing preload file and patterns matching nothing", () => { + assert.equal(run("browserNoServer").exitCode, 1); + assert.includes(err("browserNoServer"), "--no-server is not supported for browser target when --browser-launch is 'puppeteer'."); + + assert.equal(run("browserPreloadPackage").exitCode, 1); + assert.includes(err("browserPreloadPackage"), "Browser/worker preload currently supports file paths only. Unsupported preload module: some-package"); + + assert.equal(run("workerPreloadMissing").exitCode, 1); + assert.includes(err("workerPreloadMissing"), "Preload file not found: " + path.join(projects.browser, "missing-setup.ts")); + + assert.equal(run("browserNoFiles").exitCode, 1); + assert.includes(err("browserNoFiles"), "No files matched: nothing/**/*.test.ts"); + }); + + it("re-runs the browser target through puppeteer as its files change, through compile failures", () => { + const text = out("watchBrowser"); + + assert.includes(text, "Matched 2 file(s)", "the preload and the test"); + assert.includes(text, "Launching browser target via Puppeteer (--watch)..."); + assert.equal(text.split("Compile failed: TypeScript compile failed with 1 error(s)").length - 1, 2, "before the fix and after breaking it again"); + assert.equal(text.split("Run summary: total=1 passed=1").length - 1, 1, "a run once it was fixed"); + assert.equal(text.split("Watching ").length - 1, 3); + }); + + it("rejects a watch without the server, or with patterns matching nothing", () => { + assert.equal(run("watchBrowserNoServer").exitCode, 1); + assert.includes(err("watchBrowserNoServer"), "--no-server is not supported for browser target when --browser-launch is 'puppeteer'."); + + assert.equal(run("watchBrowserNoFiles").exitCode, 1); + assert.includes(err("watchBrowserNoFiles"), "No files matched: nothing/**/*.test.ts"); + }); + + it("opens the system browser for an open launch, keeping the server it needs", () => { + const invocation = run("browserOpen"); + + assert.equal(invocation.exitCode, 0, err("browserOpen")); + assert.includes(err("browserOpen"), "Ignoring --no-server because --browser-launch open requires the server to be running."); + assert.includes(out("browserOpen"), "Launching worker target via browser UI (mode: open, style: classic)..."); + assert.includes(out("browserOpen"), "Opening browser via OS default handler..."); + assert.equal(invocation.spawned.length, 1); + assert.includes(invocation.spawned[0].join(" "), "/__tripwire__/worker"); + assert.includes(out("browserOpen"), "Run summary: total=2 passed=2"); + }); + + it("loads tripwire from the project's own sources when asked to prefer them", () => { + assert.equal(run("preferSource").exitCode, 0, err("preferSource")); + assert.includes(out("preferSource"), "Run summary: total=1 passed=1"); + }); + + it("falls back to tripwire's sources when the project has none installed", () => { + assert.equal(run("noTripwireInstalled").exitCode, 0, err("noTripwireInstalled")); + assert.includes(out("noTripwireInstalled"), "Run summary: total=1 passed=1"); + }); + + it("runs with progress output when stdout is a terminal", () => { + assert.equal(run("browserTty").exitCode, 0, err("browserTty")); + assert.includes(out("browserTty"), "Run summary: total=1 passed=1"); + }); + + it("rejects a custom reporter module for the browser target", () => { + assert.equal(run("browserReporter").exitCode, 1); + assert.includes(err("browserReporter"), "Custom reporter module './my-reporter.js' is not supported for the browser target."); + }); + }); + + describe("coverage-report", () => { + it("reprints the nyc report with the erased coverage mode applied", () => { + assert.equal(run("coverageReport").exitCode, 0, err("coverageReport")); + + const summary = JSON.parse(fs.readFileSync(path.join(projects.coverage, "coverage", "coverage-summary.json"), "utf8")); + assert.equal(summary.total.statements.pct, 50); + }); + + it("rejects an unknown erased coverage mode", () => { + assert.equal(run("coverageReportBadMode").exitCode, 1); + assert.includes(err("coverageReportBadMode"), "Invalid --erased-coverage-mode 'bogus'. Expected zero, full, or ignore."); + }); + }); +}); diff --git a/internal/runner/test/src/cli/graphWorkerEntry.test.ts b/internal/runner/test/src/cli/graphWorkerEntry.test.ts new file mode 100644 index 0000000..6ce309a --- /dev/null +++ b/internal/runner/test/src/cli/graphWorkerEntry.test.ts @@ -0,0 +1,108 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { createRequire } from "module"; +import { assert } from "@nevware21/tripwire"; +import { _graphWorkerEntry } from "../../../src/tripwire"; +import { setGraphTypeCheck } from "../../../src/compile/tsCompile"; + +/** + * The entry point a module-graph worker thread runs: it builds the graph its workerData describes and + * posts back either the graph or the failure. Here it runs on this thread, with the thread's data and + * port stood in for. + */ +describe("cli graph worker entry", function () { + // The graph compiles a small TypeScript project + this.timeout(60000); + + const workerThreads = createRequire(__filename)("worker_threads"); + const orgWorkerData = workerThreads.workerData; + const orgParentPort = workerThreads.parentPort; + let project: string; + let posted: any[]; + let orgLog: typeof console.log; + + function write(name: string, text: string): string { + const file = path.join(project, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + function runEntry(entryFiles: string[], typeCheck?: boolean, withPort: boolean = true): void { + workerThreads.workerData = { entryFiles: entryFiles, cwd: project, parsed: {}, typeCheck: !!typeCheck }; + workerThreads.parentPort = withPort ? { postMessage: (result: any) => posted.push(result) } : null; + + _graphWorkerEntry(); + } + + before(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-graph-entry-")); + + // Its own tripwire package, as in core's own suite, so the graph's runtime is this small file + write("package.json", JSON.stringify({ name: "@nevware21/tripwire", version: "0.0.0" })); + write("tsconfig.json", JSON.stringify({ compilerOptions: { target: "es2018", module: "commonjs", types: [], lib: [ "es2018" ] }, include: [ "src/**/*.ts", "test/**/*.ts" ] })); + write("src/index.ts", "export function it(name: string, fn: () => void): void {\n fn();\n}\n"); + write("test/a.test.ts", "import { it } from \"@nevware21/tripwire\";\nit(\"runs\", () => {\n});\n"); + write("test/builtin.test.ts", "import * as fs from \"fs\";\nexport const exists = fs.existsSync;\n"); + }); + + after(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + beforeEach(() => { + posted = []; + orgLog = console.log; + console.log = () => {}; + }); + + afterEach(() => { + console.log = orgLog; + workerThreads.workerData = orgWorkerData; + workerThreads.parentPort = orgParentPort; + setGraphTypeCheck(false); + }); + + it("posts the graph it built for the entry files", () => { + runEntry([ path.join(project, "test/a.test.ts") ]); + + assert.equal(posted.length, 1); + assert.isTrue(posted[0].ok); + assert.deepEqual(posted[0].graph.entryIds, [ path.join(project, "test/a.test.ts").split(path.sep).join("/") ]); + }); + + it("posts a user error with its hints", () => { + runEntry([ path.join(project, "test/builtin.test.ts") ]); + + assert.isFalse(posted[0].ok); + assert.isTrue(posted[0].user); + assert.includes(posted[0].message, "Node builtin module 'fs' is not supported in browser/worker target"); + assert.isTrue(posted[0].hints.length > 0); + assert.isString(posted[0].stack); + }); + + it("posts any other failure as one that is not the user's", () => { + runEntry([ path.join(project, "test/missing.test.ts") ]); + + assert.isFalse(posted[0].ok); + assert.isFalse(posted[0].user); + assert.includes(posted[0].message, "ENOENT"); + }); + + it("posts nothing without a parent thread to post to", () => { + runEntry([ path.join(project, "test/a.test.ts") ], false, false); + + assert.equal(posted.length, 0); + }); +}); diff --git a/internal/runner/test/src/cli/parseArgs.test.ts b/internal/runner/test/src/cli/parseArgs.test.ts index 092778f..54ef05e 100644 --- a/internal/runner/test/src/cli/parseArgs.test.ts +++ b/internal/runner/test/src/cli/parseArgs.test.ts @@ -47,6 +47,25 @@ describe("CLI argument parsing", () => { it("should treat a following flag as a missing value rather than consuming it", () => { assert.includes(parse(["spec.ts", "--timeout", "--bail"]).error, "Missing value for --timeout"); }); + + it("should report the missing value of every option that takes one, by its long name", () => { + const options: { [flag: string]: string } = { + "--slow": "slow", "-s": "slow", "--retries": "retries", "--grep": "grep", "-g": "grep", "--fgrep": "fgrep", + "-f": "fgrep", "--ignore": "ignore", "--coverage-instrumenter": "coverage-instrumenter", + "--erased-coverage-mode": "erased-coverage-mode", "--coverage-reporter": "coverage-reporter", + "--coverage-src": "coverage-src", "--reporter": "reporter", "--server-root": "server-root", "--target": "target", + "--env": "env", "--browser-launch": "browser-launch", "--browser-style": "browser-style", "--ux-provider": "ux-provider", + "--project": "project", "-p": "project", "--tsProject": "project", "--ts-project": "project", + "--type-check-scope": "type-check-scope", "--spec": "spec", "--file": "file", "--require": "require", "-r": "require" + }; + + Object.keys(options).forEach((flag) => { + assert.includes(parse(["spec.ts", flag]).error, "Missing value for --" + options[flag], flag); + if (flag.indexOf("--") === 0) { + assert.includes(parse(["spec.ts", flag + "="]).error, "Missing value for --" + options[flag], flag + "= with nothing after it"); + } + }); + }); }); describe("file patterns", () => { @@ -392,6 +411,11 @@ describe("CLI argument parsing", () => { assert.equal(parsed.browserStyle, "classic"); }); + it("should take the style from --browser-style, case-insensitively", () => { + assert.equal(parse(["spec.ts", "--browser-style", "SLATE"]).browserStyle, "slate"); + assert.equal(parse(["spec.ts", "--browser-style=classic"]).browserStyle, "classic"); + }); + it("should accept each launch mode", () => { assert.equal(parse(["spec.ts", "--browser-launch", "open"]).browserLaunch, "open"); assert.equal(parse(["spec.ts", "--browser-launch=manual"]).browserLaunch, "manual"); @@ -501,6 +525,18 @@ describe("CLI argument parsing", () => { assert.equal(parse(["spec.ts", "--bind-address=127.0.0.1"], { bindAddress: "0.0.0.0" } as ITripwireConfig).bindAddress, "127.0.0.1"); }); + it("should carry the coverage instrumenter, the UX provider and the source map choice through", () => { + const parsed = parse(["spec.ts", "--coverage-instrumenter", "none", "--ux-provider=./ux.js", "--source-maps", "--no-source-maps"]); + + assert.equal(parsed.coverageInstrumenter, "none"); + assert.equal(parsed.uxProvider, "./ux.js"); + assert.isFalse(parsed.sourceMaps, "the last of --source-maps and --no-source-maps wins"); + }); + + it("should reject a --grep pattern that is not a valid regular expression", () => { + assert.includes(parse(["spec.ts", "--grep", "(unclosed"]).error, "Invalid value for --grep: (unclosed"); + }); + it("should report a missing --bind-address value", () => { assert.includes(parse(["spec.ts", "--bind-address"]).error, "Missing value for --bind-address"); }); diff --git a/internal/runner/test/src/client/runnerHost.test.ts b/internal/runner/test/src/client/runnerHost.test.ts new file mode 100644 index 0000000..0abd083 --- /dev/null +++ b/internal/runner/test/src/client/runnerHost.test.ts @@ -0,0 +1,165 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { applyGlobalRunnerConfig, createContextServer, createRunnerHost } from "../../../src/client/runnerHost"; +import { _loadUxProvider } from "../../../src/client/loadUxProvider"; +import * as defaultUx from "../../../src/ux"; +import { _isUserError } from "../../../src/internal/userError"; + +/** + * What the browser and worker runtimes build around a run in the page: the runner host, the view of + * the CLI's server the payload describes, and the run settings applied to tripwire's configuration. + * Also how the CLI picks the browser UX it serves. + */ +describe("client runner host", () => { + describe("createContextServer", () => { + it("describes the server named in the run settings", () => { + const server = createContextServer({ server: { url: "http://127.0.0.1:1234/", port: 1234, isRunning: true } }); + + assert.equal(server.url, "http://127.0.0.1:1234/"); + assert.equal(server.port, 1234); + assert.isTrue(server.isRunning); + }); + + it("defaults the port and treats a server as running unless told otherwise", () => { + assert.equal(createContextServer({ server: { url: "http://host/", port: "80" } }).port, 0); + assert.isTrue(createContextServer({ server: { url: "http://host/" } }).isRunning); + assert.isFalse(createContextServer({ server: { url: "http://host/", isRunning: false } }).isRunning); + }); + + it("starts and stops only its own running flag", async () => { + const server = createContextServer({ server: { url: "http://host/", isRunning: false } }); + + assert.equal(server.start(), server); + assert.isTrue(server.isRunning); + await server.stop(); + assert.isFalse(server.isRunning); + }); + + it("has no server without settings naming one", () => { + assert.isNull(createContextServer(null)); + assert.isNull(createContextServer({})); + assert.isNull(createContextServer({ server: { port: 80 } })); + }); + }); + + describe("createRunnerHost", () => { + it("works on a clone of the configuration, with its own notifications and the settings' server", () => { + const clone = { cloned: true }; + const notifyMgr = { notify: true }; + const tripwire: any = { + assertConfig: { $ops: { clone: () => clone } }, + createNotifyMgr: () => notifyMgr + }; + + const host = createRunnerHost(tripwire, { server: { url: "http://host/" } }); + + assert.equal(host.opts.v, clone); + assert.equal(host.notifyMgr, notifyMgr); + assert.equal(host.serverMgr.server.url, "http://host/"); + assert.equal(host.serverMgr.start(), host.serverMgr.server); + host.serverMgr.setProvider({}); + }); + + it("uses the configuration as it is when it cannot be cloned, and copes without a server or notifications", () => { + const config = { timeout: 5 }; + const host = createRunnerHost({ assertConfig: config } as any, {}); + + assert.equal(host.opts.v, config); + assert.isNull(host.notifyMgr); + assert.isNull(host.serverMgr.server); + assert.isNull(host.serverMgr.start()); + }); + + it("clones into a fresh host for the same module and settings", () => { + const tripwire: any = { assertConfig: { $ops: { clone: () => ({}) } } }; + const host = createRunnerHost(tripwire, { server: { url: "http://host/" } }); + + const copy = host.clone(); + + assert.notEqual(copy, host); + assert.equal(copy.serverMgr.server.url, "http://host/"); + }); + }); + + describe("applyGlobalRunnerConfig", () => { + it("applies the run's timeout, slow and retries settings, and puts the old values back", () => { + const config: any = { timeout: 2000, slow: 75, retries: 0, other: "kept" }; + const restore = applyGlobalRunnerConfig({ assertConfig: config }, { timeout: 100, retries: 2, unrelated: true }); + + assert.deepEqual(config, { timeout: 100, slow: 75, retries: 2, other: "kept" }); + + restore(); + assert.deepEqual(config, { timeout: 2000, slow: 75, retries: 0, other: "kept" }); + }); + + it("does nothing without a configuration or settings", () => { + applyGlobalRunnerConfig(null, { timeout: 1 })(); + applyGlobalRunnerConfig({}, { timeout: 1 })(); + + const config: any = { timeout: 3 }; + applyGlobalRunnerConfig({ assertConfig: config }, null)(); + assert.equal(config.timeout, 3); + }); + }); + + describe("_loadUxProvider", () => { + let scratch: string; + + function provider(name: string, source: string): string { + const file = path.join(scratch, name + ".js"); + fs.writeFileSync(file, source, "utf8"); + + return file; + } + + function failure(name: string): any { + try { + _loadUxProvider(name); + } catch (e) { + return e; + } + + return null; + } + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-ux-provider-")); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + it("uses the built in UX unless a provider is named", () => { + assert.equal(_loadUxProvider(), defaultUx as any); + assert.equal(_loadUxProvider(""), defaultUx as any); + }); + + it("loads a named provider with both page builders", () => { + const name = provider("complete", "exports.createBrowserRunnerHtml = () => 'page';\nexports.createFsDirListingHtml = () => 'listing';\n"); + + assert.equal(_loadUxProvider(name).createBrowserRunnerHtml("browser", "classic"), "page"); + }); + + it("rejects a provider missing either page builder as a user error", () => { + const noPage = failure(provider("noPage", "exports.createFsDirListingHtml = () => 'listing';\n")); + const noListing = failure(provider("noListing", "exports.createBrowserRunnerHtml = () => 'page';\n")); + + assert.isTrue(_isUserError(noPage)); + assert.includes(noPage.message, "missing createBrowserRunnerHtml(target, style)"); + assert.isTrue(_isUserError(noListing)); + assert.includes(noListing.message, "missing createFsDirListingHtml(style, relativePath, entries)"); + }); + }); +}); diff --git a/internal/runner/test/src/client/workerRunner.test.ts b/internal/runner/test/src/client/workerRunner.test.ts new file mode 100644 index 0000000..0442433 --- /dev/null +++ b/internal/runner/test/src/client/workerRunner.test.ts @@ -0,0 +1,201 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as tripwire from "@nevware21/tripwire"; +import { assert } from "@nevware21/tripwire"; +import { startWorkerRunner } from "../../../src/client/workerRunner"; + +/** + * The script the worker target runs inside its web worker: it loads the bundled test modules, runs them + * with tripwire, and posts its progress and results back to the page. Here it runs on node with the + * worker's global, script loading and messages stood in for, and the real tripwire as the runtime. + */ +describe("client worker runner", function () { + this.timeout(30000); + + const g = globalThis as any; + let messages: any[]; + let scripts: { [url: string]: () => void }; + + // The modules the page would serve: tripwire itself, and a test module using it + function defineModules(scope: any): void { + scripts = { + "/tripwire.js": () => scope.__tripwireDefine__("/tripwire", (_require: any, module: any) => { + module.exports = tripwire; + }), + "/test/a.js": () => scope.__tripwireDefine__("/test/a", (require: any) => { + const tw = require("/tripwire"); + tw.describe("worker suite", () => { + tw.it("passes", () => { + tw.assert.equal(1, 1); + }); + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + }); + }), + "/test/failFirst.js": () => scope.__tripwireDefine__("/test/failFirst", (require: any) => { + const tw = require("/tripwire"); + tw.describe("fails first", () => { + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + tw.it("would pass", () => { + tw.assert.equal(1, 1); + }); + }); + }), + "/src/extra.js": () => scope.__tripwireDefine__("/src/extra", (_require: any, module: any) => { + module.exports = "only loaded for coverage"; + }), + "/broken.js": () => { + throw new Error("unexpected token"); + } + }; + } + + function payload(extra?: any): any { + return Object.assign({ + moduleUrls: { tripwire: "/tripwire.js", a: "/test/a.js", extra: "/src/extra.js" }, + tripwireModuleId: "/tripwire", + entryIds: [ "/test/a" ], + coverageOnlyIds: [ "/src/extra", "/not-in-the-bundle" ], + settings: { reporter: [ "dot" ] } + }, extra || {}); + } + + // Sends one message to a freshly started worker runner and collects what it posts back. The + // runner bridges the console into its messages for good, so the console is put back afterwards. + async function post(data: any): Promise { + const scope: any = { + postMessage: (msg: any) => messages.push(msg) + }; + const orgConsole = { log: console.log, info: console.info, warn: console.warn, error: console.error }; + + g.self = scope; + g.importScripts = (url: string) => { + if (!scripts[url]) { + throw new Error("404 " + url); + } + + scripts[url](); + }; + + try { + defineModules(scope); + startWorkerRunner(); + await scope.onmessage({ data: data }); + } finally { + console.log = orgConsole.log; + console.info = orgConsole.info; + console.warn = orgConsole.warn; + console.error = orgConsole.error; + delete g.self; + delete g.importScripts; + } + + return messages; + } + + function ofKind(kind: string): any[] { + return messages.filter((msg) => msg.kind === kind); + } + + beforeEach(() => { + messages = []; + }); + + it("runs the test modules and posts the summary", async () => { + await post({ payload: payload(), autoFinish: true }); + + const result = messages[messages.length - 1]; + assert.equal(result.summary.total, 2); + assert.equal(result.summary.passed, 1); + assert.equal(result.summary.failed, 1); + assert.isNull(result.coverage); + assert.equal(ofKind("testStart").length, 0, "no progress for a run that finishes on its own"); + }); + + it("posts each suite, test and assertion as it runs for the interactive page", async () => { + await post({ payload: payload() }); + + assert.deepEqual(ofKind("suiteStart").map((msg) => msg.suiteParts), [ [ "worker suite" ] ]); + assert.deepEqual(ofKind("testStart").map((msg) => msg.testName), [ "passes", "fails" ]); + assert.deepEqual(ofKind("testDone").map((msg) => [ msg.testName, msg.endStatus ]), [ [ "passes", "passed" ], [ "fails", "failed" ] ]); + assert.equal(ofKind("suiteDone").length, 1); + + const asserts = ofKind("assert"); + assert.isTrue(asserts.length >= 2); + assert.isTrue(asserts[0].passed); + assert.equal(asserts[0].testName, "passes"); + const failed = asserts.filter((msg) => !msg.passed)[0]; + assert.equal(failed.testName, "fails"); + assert.includes(failed.errorText, "expected 1 to equal 2"); + }); + + it("sends the reporter's console output to the page", async () => { + await post({ payload: payload({ settings: { reporter: [ "spec" ], slow: 10, trace: true } }), autoFinish: true }); + + const logged = ofKind("log").map((msg) => msg.message).join("\n"); + assert.includes(logged, "worker suite"); + assert.includes(logged, "passes"); + }); + + it("stops at the first failure with bail", async () => { + const failFirst = { moduleUrls: { tripwire: "/tripwire.js", failFirst: "/test/failFirst.js" }, entryIds: [ "/test/failFirst" ] }; + + await post({ payload: payload(Object.assign({ settings: { reporter: [ "dot" ] } }, failFirst)), autoFinish: true }); + assert.equal(messages[messages.length - 1].summary.passed, 1, "without bail the second test runs"); + + messages = []; + await post({ payload: payload(Object.assign({ settings: { bail: true, reporter: [ "dot" ] } }, failFirst)), autoFinish: true }); + const result = messages[messages.length - 1]; + assert.equal(result.summary.failed, 1); + assert.equal(result.summary.passed, 0, "nothing runs after the failure"); + }); + + it("only runs the test the page selected, with the run's timeout", async () => { + await post({ payload: payload({ settings: { timeout: 1234, reporter: [ "dot" ] } }), selection: { testTitle: "worker suite > passes" }, autoFinish: true }); + + const result = messages[messages.length - 1]; + assert.equal(result.summary.passed, 1); + assert.equal(result.summary.failed, 0); + }); + + it("posts the error when the run cannot start", async () => { + await post({ payload: payload({ tripwireModuleId: "/not-defined" }), autoFinish: true }); + + assert.includes(messages[messages.length - 1].error, "Module not found in registry: /not-defined"); + }); + + it("posts the module scripts that failed to load", async () => { + await post({ payload: payload({ moduleUrls: { tripwire: "/tripwire.js", broken: "/broken.js", gone: "/gone.js" } }) }); + + const error = messages[messages.length - 1].error; + assert.includes(error, "Failed to load 2 worker module script(s): /broken.js (unexpected token), /gone.js (404 /gone.js)"); + }); + + describe("discover", () => { + it("posts the suites and tests the modules declare", async () => { + await post({ kind: "discover", payload: payload() }); + + const discovery = ofKind("discovery")[0]; + assert.isUndefined(discovery.error); + assert.includes(JSON.stringify(discovery.discovery), "worker suite"); + assert.includes(JSON.stringify(discovery.discovery), "fails"); + }); + + it("posts an empty discovery with the error when the modules cannot be loaded", async () => { + await post({ kind: "discover", payload: payload({ entryIds: [ "/not-defined" ] }) }); + + const discovery = ofKind("discovery")[0]; + assert.includes(discovery.error, "Module not found in registry: /not-defined"); + assert.isObject(discovery.discovery); + }); + }); +}); diff --git a/internal/runner/test/src/codecov/instrumenter.test.ts b/internal/runner/test/src/codecov/instrumenter.test.ts new file mode 100644 index 0000000..610ae42 --- /dev/null +++ b/internal/runner/test/src/codecov/instrumenter.test.ts @@ -0,0 +1,224 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as ts from "typescript"; +import { assert } from "@nevware21/tripwire"; +import { createInstrumenter, extractSourceMap, IstanbulCoverageInstrumenter } from "../../../src/codecov/instrumenter"; +import { _isUserError, _userErrorHints } from "../../../src/internal/userError"; + +function captureWarnings(fn: () => void): string[] { + const warnings: string[] = []; + const orgWarn = console.warn; + console.warn = (...args: any[]) => { + warnings.push(args.join(" ")); + }; + + try { + fn(); + } finally { + console.warn = orgWarn; + } + + return warnings; +} + +function inlineMap(map: any): string { + return "//# sourceMappingURL=data:application/json;charset=utf-8;base64," + Buffer.from(JSON.stringify(map)).toString("base64"); +} + +/** + * The coverage instrumenter rewrites the browser and worker payloads so they record coverage. It loads an + * istanbul style module from the project, and accepts several shapes of module API. + */ +describe("codecov instrumenter", () => { + let scratch: string; + + function writeModule(name: string, source: string): string { + const file = path.join(scratch, name + ".js"); + fs.writeFileSync(file, source, "utf8"); + + return file; + } + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-instrumenter-")); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("extractSourceMap", () => { + const map = { version: 3, sources: [ "a.ts" ], names: [] as string[], mappings: "AAAA" }; + + it("reads an inline base64 source map", () => { + assert.deepEqual(extractSourceMap("var a = 1;\n" + inlineMap(map), "/proj/a.js"), map); + assert.deepEqual(extractSourceMap("var a = 1;\n//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify(map)).toString("base64"), "/proj/a.js"), map, + "the charset is optional"); + }); + + it("returns null for an inline map that is not JSON", () => { + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=data:application/json;base64," + Buffer.from("not json").toString("base64"), "/proj/a.js")); + }); + + it("reads a map file next to the module, decoding its name", () => { + const dir = path.join(scratch, "maps"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "my file.js.map"), JSON.stringify(map), "utf8"); + + assert.deepEqual(extractSourceMap("var a;\n//# sourceMappingURL=my%20file.js.map", path.join(dir, "module.js")), map); + }); + + it("returns null when there is no map, the map file is missing or unreadable, or the data url is not base64 JSON", () => { + const dir = path.join(scratch, "maps"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "broken.js.map"), "{", "utf8"); + + assert.isNull(extractSourceMap("var a = 1;", "/proj/a.js")); + assert.isNull(extractSourceMap("", "/proj/a.js")); + assert.isNull(extractSourceMap(undefined, "/proj/a.js")); + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=missing.js.map", path.join(dir, "module.js"))); + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=broken.js.map", path.join(dir, "module.js"))); + assert.isNull(extractSourceMap("var a;\n//# sourceMappingURL=data:text/plain,hello", path.join(dir, "module.js"))); + }); + }); + + describe("with istanbul-lib-instrument", () => { + it("instruments a module and reports the file's coverage map", () => { + const instrumenter = createInstrumenter(scratch); + assert.ok(instrumenter instanceof IstanbulCoverageInstrumenter); + + const moduleId = path.join(scratch, "src", "a.js"); + const output = instrumenter.instrument("var a = 1;\nfunction f() { return a; }\n", moduleId); + + assert.includes(output, "__coverage__"); + const coverage = instrumenter.getLastFileCoverage(); + assert.equal(coverage.path, moduleId); + assert.isTrue(Object.keys(coverage.statementMap).length >= 2); + assert.equal(Object.keys(coverage.fnMap).length, 1); + }); + + it("maps the coverage back through the input source map", () => { + const compiled = ts.transpileModule("export const answer: number = 42;\nexport function ask(): number { return answer; }\n", { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES5, inlineSourceMap: true, inlineSources: true }, + fileName: path.join(scratch, "src", "b.ts") + }).outputText; + const moduleId = path.join(scratch, "src", "b.js"); + const sourceMap = extractSourceMap(compiled, moduleId); + const instrumenter = createInstrumenter(scratch); + + assert.isObject(sourceMap); + instrumenter.instrument(compiled, moduleId, sourceMap); + assert.isObject(instrumenter.getLastFileCoverage().inputSourceMap, "the input map travels with the coverage"); + }); + + it("reports a missing instrumenter module as a user error with install hints", () => { + const instrumenter = new IstanbulCoverageInstrumenter(scratch, "definitely-not-an-instrumenter"); + + let failure: any = null; + try { + instrumenter.instrument("var a;", "/proj/a.js"); + } catch (e) { + failure = e; + } + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "unable to resolve 'definitely-not-an-instrumenter'"); + assert.includes(_userErrorHints(failure)[0], "npm install --save-dev definitely-not-an-instrumenter"); + }); + + it("reports a module without an instrumenter API as a user error", () => { + const instrumenter = new IstanbulCoverageInstrumenter(scratch, writeModule("noApi", "module.exports = { other: 1 };")); + + assert.throws(() => instrumenter.getLastFileCoverage(), "does not expose createInstrumenter or instrumentSync"); + }); + }); + + describe("createInstrumenter", () => { + it("passes the source through unchanged when instrumentation is turned off", () => { + [ "none", "NONE", "" ].forEach((name) => { + assert.equal(createInstrumenter(scratch, name).instrument("var a = 1;", "/proj/a.js"), "var a = 1;", JSON.stringify(name)); + }); + }); + + it("warns and passes the source through when the module cannot be resolved", () => { + let instrumenter: any; + const warnings = captureWarnings(() => { + instrumenter = createInstrumenter(scratch, "definitely-not-an-instrumenter"); + }); + + assert.includes(warnings[0], "unable to resolve 'definitely-not-an-instrumenter'"); + assert.equal(instrumenter.instrument("src", "/proj/a.js"), "src"); + }); + + it("uses a module exposing instrumentSync directly, and its last file coverage", () => { + const file = writeModule("syncApi", [ + "let last = null;", + "module.exports = {", + " instrumentSync: (src, id, map) => { last = { path: id, map: !!map }; return '/*sync*/' + src; },", + " lastFileCoverage: () => last", + "};" + ].join("\n")); + const instrumenter = createInstrumenter(scratch, file); + + assert.equal(instrumenter.instrument("x", "/proj/a.js"), "/*sync*/x"); + assert.deepEqual(instrumenter.getLastFileCoverage(), { path: "/proj/a.js", map: false }); + instrumenter.instrument("x", "/proj/b.js", { mappings: "AAAA" }); + assert.deepEqual(instrumenter.getLastFileCoverage(), { path: "/proj/b.js", map: true }); + }); + + it("unwraps a default export that creates the instrumenter, and copes without lastFileCoverage", () => { + const file = writeModule("defaultApi", [ + "module.exports = { default: { createInstrumenter: (options) => ({", + " instrumentSync: (src) => '/*' + options.coverageVariable + '*/' + src", + "}) } };" + ].join("\n")); + const instrumenter = createInstrumenter(scratch, file); + + assert.equal(instrumenter.instrument("x", "/proj/a.js"), "/*__coverage__*/x"); + assert.isNull(instrumenter.getLastFileCoverage()); + }); + + it("accepts a factory that returns an instrumentSync object", () => { + const file = writeModule("factoryObject", [ + "module.exports = (options, ctx) => ({", + " instrumentSync: (src, id, map) => '/*' + ctx.cwd.length + (map ? ':map' : '') + '*/' + src", + "});" + ].join("\n")); + const instrumenter = createInstrumenter(scratch, file); + + assert.equal(instrumenter.instrument("x", "/proj/a.js"), "/*" + scratch.length + "*/x"); + assert.equal(instrumenter.instrument("x", "/proj/a.js", { mappings: "AAAA" }), "/*" + scratch.length + ":map*/x"); + }); + + it("accepts a factory that returns an instrument function, keeping the source when it does not answer with a string", () => { + const fnStyle = createInstrumenter(scratch, writeModule("factoryFn", "module.exports = () => (src, id) => '/*' + id + '*/' + src;")); + const notString = createInstrumenter(scratch, writeModule("factoryFnNumber", "module.exports = () => () => 42;")); + + assert.equal(fnStyle.instrument("x", "a.js"), "/*a.js*/x"); + assert.equal(notString.instrument("x", "a.js"), "x"); + }); + + it("warns and passes the source through for a module it cannot use", () => { + let objectApi: any; + let emptyFactory: any; + const warnings = captureWarnings(() => { + objectApi = createInstrumenter(scratch, writeModule("objectNoApi", "module.exports = { nothing: true };")); + emptyFactory = createInstrumenter(scratch, writeModule("factoryNothing", "module.exports = () => undefined;")); + }); + + assert.equal(warnings.length, 2); + warnings.forEach((w) => assert.includes(w, "does not expose a supported API")); + assert.equal(objectApi.instrument("x", "a.js"), "x"); + assert.equal(emptyFactory.instrument("x", "a.js"), "x"); + }); + }); +}); diff --git a/internal/runner/test/src/codecov/provider.test.ts b/internal/runner/test/src/codecov/provider.test.ts new file mode 100644 index 0000000..33ab10e --- /dev/null +++ b/internal/runner/test/src/codecov/provider.test.ts @@ -0,0 +1,330 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { DefaultCoverageFilter } from "../../../src/codecov/filter"; +import { CoverageData, CoverageEntry, ICoverageInstrumenter, ICoverageReporter } from "../../../src/codecov/interfaces"; +import { _asFullyCovered, _asZeroCovered, _isErasedEntry, createDefaultCoverageProvider, DefaultCoverageProvider } from "../../../src/codecov/provider"; +import { NycCoverageReporter } from "../../../src/codecov/reporter"; + +function entry(file: string, statements: number, functions?: number): CoverageEntry { + const statementMap: any = {}; + const s: any = {}; + for (let lp = 0; lp < statements; lp++) { + statementMap[lp] = { start: { line: lp + 1, column: 0 }, end: { line: lp + 1, column: 5 } }; + s[lp] = 0; + } + + const fnMap: any = {}; + const f: any = {}; + for (let lp = 0; lp < (functions || 0); lp++) { + fnMap[lp] = { name: "f" + lp, decl: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } } }; + f[lp] = 0; + } + + return { path: file, statementMap, fnMap, branchMap: {}, s, f, b: {} }; +} + +/** + * An instrumenter that records what it was asked to instrument, and answers with a made-up coverage + * map per file so the provider's bookkeeping can be checked without istanbul. + */ +function recordingInstrumenter(coverageFor?: (file: string) => CoverageEntry | null, failFor?: RegExp): ICoverageInstrumenter & { calls: any[] } { + let last: CoverageEntry | null = null; + const instrumenter = { + calls: [] as any[], + instrument(source: string, moduleId: string, inputSourceMap?: any): string { + instrumenter.calls.push({ moduleId, hasMap: !!inputSourceMap }); + if (failFor && failFor.test(moduleId)) { + throw new Error("cannot instrument " + moduleId); + } + + last = coverageFor ? coverageFor(moduleId) : null; + return "/*instrumented*/" + source; + }, + getLastFileCoverage(): CoverageEntry | null { + return last; + } + }; + + return instrumenter; +} + +function recordingReporter(): ICoverageReporter & { writes: any[] } { + const reporter = { + writes: [] as any[], + write(coverage: CoverageData, cwd: string, target: string): void { + reporter.writes.push({ coverage, cwd, target }); + } + }; + + return reporter; +} + +function captureWarnings(fn: () => void): string[] { + const warnings: string[] = []; + const orgWarn = console.warn; + console.warn = (...args: any[]) => { + warnings.push(args.join(" ")); + }; + + try { + fn(); + } finally { + console.warn = orgWarn; + } + + return warnings; +} + +describe("codecov provider", () => { + describe("erased entries", () => { + it("treats a file with no functions, no branches and at most one statement as erased", () => { + assert.isTrue(_isErasedEntry({ path: "a" })); + assert.isTrue(_isErasedEntry(entry("a", 0))); + assert.isTrue(_isErasedEntry(entry("a", 1))); + assert.isFalse(_isErasedEntry(entry("a", 2))); + assert.isFalse(_isErasedEntry(entry("a", 0, 1))); + assert.isFalse(_isErasedEntry(Object.assign(entry("a", 0), { branchMap: { 0: {} }, b: { 0: [ 0 ] } }))); + }); + + it("zeroes every counter without touching the original", () => { + const original = Object.assign(entry("a", 2, 1), { b: { 0: [ 3, 4 ] }, branchMap: { 0: {} } }); + original.s[0] = 5; + original.f[0] = 2; + + const zeroed = _asZeroCovered(original); + + assert.deepEqual(zeroed.s, { 0: 0, 1: 0 }); + assert.deepEqual(zeroed.f, { 0: 0 }); + assert.deepEqual(zeroed.b, { 0: [ 0, 0 ] }); + assert.equal(original.s[0], 5, "the original is not changed"); + assert.deepEqual(_asZeroCovered({ path: "empty" }).s, {}); + }); + + it("marks every counter covered, giving a file without statements one covered statement", () => { + const covered = _asFullyCovered(Object.assign(entry("a", 2, 1), { b: { 0: [ 0, 0 ] }, branchMap: { 0: {} } })); + + assert.deepEqual(covered.s, { 0: 1, 1: 1 }); + assert.deepEqual(covered.f, { 0: 1 }); + assert.deepEqual(covered.b, { 0: [ 1, 1 ] }); + + const synthetic = _asFullyCovered(entry("types", 0)); + assert.deepEqual(synthetic.s, { 0: 1 }); + assert.deepEqual(Object.keys(synthetic.statementMap), [ "0" ]); + assert.deepEqual(synthetic.fnMap, {}); + assert.deepEqual(synthetic.b, {}); + assert.deepEqual(_asFullyCovered({ path: "bare", statementMap: { 0: {} } }).f, {}, "missing counters are treated as empty"); + }); + }); + + describe("instrumentPayload", () => { + const cwd = "/proj"; + const sourceMapComment = "//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify({ version: 3, sources: [ "b.ts" ], mappings: "AAAA" })).toString("base64"); + + function payload(): any { + return { + settings: { timeout: 10 }, + entryIds: [ "/proj/test/a.test.js" ], + tripwireModuleId: "/proj/node_modules/@nevware21/tripwire/tripwire.js", + coverageOnlyIds: [ "/proj/src/only.js" ], + modules: { + "/proj/test/a.test.js": "require('/proj/src/b.js'); require(\"/proj/src/throws.js\");", + "/proj/src/b.js": "require( '/proj/src/c.js' ); require('/proj/src/missing.js');\n" + sourceMapComment, + "/proj/src/c.js": "module.exports = 1; require('/proj/src/b.js');", + "/proj/src/only.js": "module.exports = 'only';", + "/proj/src/unused.js": "module.exports = 'never required';", + "/proj/src/throws.js": "module.exports = 'breaks the instrumenter';", + "/proj/test/helper.test.js": "module.exports = 'a test file';", + "/proj/node_modules/@nevware21/tripwire/tripwire.js": "require('/proj/node_modules/dep/index.js');", + "/proj/node_modules/dep/index.js": "module.exports = 'dependency';" + } + }; + } + + it("instruments only the reachable, in-scope, non-test modules", () => { + const instrumenter = recordingInstrumenter(undefined, /throws\.js$/); + const provider = new DefaultCoverageProvider(instrumenter, new DefaultCoverageFilter(), recordingReporter()); + + const result = provider.instrumentPayload(payload(), cwd, {}); + const instrumented = Object.keys(result.modules).filter((id) => result.modules[id].indexOf("/*instrumented*/") === 0).sort(); + + assert.deepEqual(instrumented, [ "/proj/src/b.js", "/proj/src/c.js", "/proj/src/only.js" ]); + assert.equal(result.modules["/proj/src/unused.js"], "module.exports = 'never required';", "an unreachable module is left alone"); + assert.equal(result.modules["/proj/src/throws.js"], "module.exports = 'breaks the instrumenter';", "a module the instrumenter rejects is served as it was"); + assert.equal(result.modules["/proj/test/a.test.js"], payload().modules["/proj/test/a.test.js"], "the entry (a test) is never instrumented"); + assert.isFalse(result.modules["/proj/node_modules/dep/index.js"].indexOf("/*instrumented*/") === 0, "dependencies are out of scope"); + }); + + it("passes a module's own source map to the instrumenter", () => { + const instrumenter = recordingInstrumenter(); + new DefaultCoverageProvider(instrumenter, new DefaultCoverageFilter(), recordingReporter()).instrumentPayload(payload(), cwd, {}); + + const calls: { [id: string]: boolean } = {}; + instrumenter.calls.forEach((call) => { + calls[call.moduleId] = call.hasMap; + }); + + assert.isTrue(calls["/proj/src/b.js"]); + assert.isFalse(calls["/proj/src/c.js"]); + }); + + it("keeps the settings, entries and tripwire module but not the coverage-only list", () => { + const result = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), recordingReporter()).instrumentPayload(payload(), cwd, {}); + + assert.deepEqual(result.settings, { timeout: 10 }); + assert.deepEqual(result.entryIds, [ "/proj/test/a.test.js" ]); + assert.equal(result.tripwireModuleId, "/proj/node_modules/@nevware21/tripwire/tripwire.js"); + assert.isUndefined(result.coverageOnlyIds); + }); + + it("returns a payload without modules as it is", () => { + const provider = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), recordingReporter()); + const empty = { entryIds: [ "a" ] }; + + assert.equal(provider.instrumentPayload(empty, cwd, {}), empty); + assert.isNull(provider.instrumentPayload(null, cwd, {})); + }); + + it("copes with a payload that names no entries or tripwire module", () => { + const result = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), recordingReporter()) + .instrumentPayload({ modules: { "/proj/src/a.js": "x" } }, cwd, {}); + + assert.equal(result.modules["/proj/src/a.js"], "x", "nothing is reachable, so nothing is instrumented"); + }); + }); + + describe("writeCoverage and fillUninstrumentedFiles", function () { + // Filling in the uninstrumented files transpiles them with TypeScript + this.timeout(60000); + + let project: string; + const file = (rel: string) => path.join(project, rel).split(path.sep).join("/"); + + before(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-provider-")); + fs.mkdirSync(path.join(project, "src")); + fs.mkdirSync(path.join(project, "lib")); + fs.writeFileSync(path.join(project, "src", "covered.ts"), "export const covered = 1;\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "never.ts"), "export function never(): number { return 2; }\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "types.ts"), "export interface IShape { size: number; }\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "broken.ts"), "export const broken = 3;\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "index.ts"), "export * from \"./covered\";\n", "utf8"); + fs.writeFileSync(path.join(project, "src", "spec.test.ts"), "export const t = 1;\n", "utf8"); + fs.writeFileSync(path.join(project, "lib", "other.ts"), "export const other = 4;\n", "utf8"); + }); + + after(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + function coverageFor(moduleId: string): CoverageEntry | null { + const normalized = moduleId.split("\\").join("/"); + if (/types\.ts$/.test(normalized)) { + return Object.assign(entry(normalized, 0), { all: true }); + } + + return entry(normalized, 2, 1); + } + + it("adds a zero-coverage entry for every source file the run never loaded", () => { + const instrumenter = recordingInstrumenter(coverageFor, /broken\.ts$/); + const provider = new DefaultCoverageProvider(instrumenter, new DefaultCoverageFilter(), recordingReporter(), {}); + const loaded: CoverageData = {}; + loaded[file("src/covered.ts")] = entry(file("src/covered.ts"), 3); + + const filled = provider.fillUninstrumentedFiles(loaded, project); + const keys = Object.keys(filled).map((k) => path.basename(k)).sort(); + + assert.deepEqual(keys, [ "covered.ts", "never.ts", "types.ts" ], "index files, tests and files that fail to instrument are left out"); + assert.equal(Object.keys(filled[file("src/covered.ts")].statementMap).length, 3, "a loaded file keeps its own coverage"); + assert.isFalse(instrumenter.calls.some((c) => /covered\.ts$/.test(c.moduleId)), "a loaded file is not instrumented again"); + }); + + it("reads the source patterns from the run options", () => { + const provider = new DefaultCoverageProvider(recordingInstrumenter(coverageFor), new DefaultCoverageFilter(), recordingReporter(), { coverageSrc: [ "lib/**/*.ts" ] }); + + assert.deepEqual(Object.keys(provider.fillUninstrumentedFiles({}, project)).map((k) => path.basename(k)), [ "other.ts" ]); + }); + + it("applies the erased coverage mode to files that compile away entirely", () => { + function typesEntryFor(mode?: string): CoverageEntry | undefined { + const provider = new DefaultCoverageProvider(recordingInstrumenter(coverageFor), new DefaultCoverageFilter(), recordingReporter(), mode ? { erasedCoverageMode: mode } : {}); + return provider.fillUninstrumentedFiles({}, project)[file("src/types.ts")]; + } + + assert.deepEqual(typesEntryFor().s, {}, "zero is the default: the empty file stays uncovered"); + assert.deepEqual(typesEntryFor("zero").s, {}); + assert.deepEqual(typesEntryFor("full").s, { 0: 1 }, "full counts it as covered"); + assert.isUndefined(typesEntryFor("ignore"), "ignore leaves it out of the report"); + }); + + it("does not add files when the instrumenter cannot report a file's coverage", () => { + const noLast: ICoverageInstrumenter = { instrument: (source) => source }; + const provider = new DefaultCoverageProvider(noLast, new DefaultCoverageFilter(), recordingReporter(), {}); + + assert.deepEqual(provider.fillUninstrumentedFiles({}, project), {}); + }); + + it("writes the filled and filtered coverage through the reporter", () => { + const reporter = recordingReporter(); + const provider = new DefaultCoverageProvider(recordingInstrumenter(coverageFor), new DefaultCoverageFilter(), reporter, {}); + const loaded: CoverageData = {}; + loaded[file("src/covered.ts")] = entry(file("src/covered.ts"), 1, 1); + loaded[file("src/spec.test.ts")] = entry(file("src/spec.test.ts"), 1, 1); + + provider.writeCoverage(loaded, project, "worker"); + + assert.equal(reporter.writes.length, 1); + assert.equal(reporter.writes[0].target, "worker"); + assert.equal(reporter.writes[0].cwd, project); + const written = Object.keys(reporter.writes[0].coverage).map((k) => path.basename(k)).sort(); + assert.deepEqual(written, [ "broken.ts", "covered.ts", "never.ts", "types.ts" ], "the test file is filtered out"); + }); + + it("writes nothing for missing coverage, and warns when nothing but tests is left", () => { + const reporter = recordingReporter(); + const provider = new DefaultCoverageProvider(recordingInstrumenter(), new DefaultCoverageFilter(), reporter, { coverageSrc: [ "nothing/**/*.ts" ] }); + const onlyTests: CoverageData = {}; + onlyTests[file("src/spec.test.ts")] = entry(file("src/spec.test.ts"), 2); + + provider.writeCoverage(null, project, "browser"); + provider.writeCoverage("not coverage" as any, project, "browser"); + const warnings = captureWarnings(() => { + provider.writeCoverage(onlyTests, project, "browser"); + provider.writeCoverage(onlyTests, project, "worker"); + }); + + assert.equal(reporter.writes.length, 0); + assert.deepEqual(warnings, [ + "No non-test coverage entries available for browser report.", + "No non-test coverage entries available for worker report." + ]); + }); + }); + + describe("createDefaultCoverageProvider", () => { + it("assembles the provider from the run options", () => { + const provider = createDefaultCoverageProvider("/proj", { coverageInstrumenter: " none ", coverageReporters: [ "lcov" ] }); + + assert.ok(provider instanceof DefaultCoverageProvider); + assert.ok(provider.filter instanceof DefaultCoverageFilter); + assert.ok(provider.reporter instanceof NycCoverageReporter); + assert.equal(provider.instrumenter.instrument("x", "/proj/a.js"), "x", "the instrumenter name is trimmed and 'none' disables it"); + }); + + it("defaults to istanbul-lib-instrument", () => { + const provider = createDefaultCoverageProvider(process.cwd(), null); + + assert.includes(provider.instrumenter.instrument("var a = 1;", path.join(process.cwd(), "src", "a.js")), "__coverage__"); + }); + }); +}); diff --git a/internal/runner/test/src/codecov/reports.test.ts b/internal/runner/test/src/codecov/reports.test.ts new file mode 100644 index 0000000..ff002ef --- /dev/null +++ b/internal/runner/test/src/codecov/reports.test.ts @@ -0,0 +1,248 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { runCoverageReport } from "../../../src/codecov/nodeCoverageReport"; +import { _buildReporterArgs, _nycCwd, _spawnNycReport } from "../../../src/codecov/nycReport"; +import { NycCoverageReporter } from "../../../src/codecov/reporter"; + +function covered(file: string, hits: number): any { + return { + path: file, + statementMap: { + 0: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } }, + 1: { start: { line: 2, column: 0 }, end: { line: 2, column: 10 } } + }, + fnMap: {}, + branchMap: {}, + s: { 0: hits, 1: 0 }, + f: {}, + b: {} + }; +} + +function erased(file: string): any { + return { path: file, statementMap: {}, fnMap: {}, branchMap: {}, s: {}, f: {}, b: {}, all: true }; +} + +function captureConsole(fn: () => any): { result: any; warnings: string[]; logs: string[] } { + const warnings: string[] = []; + const logs: string[] = []; + const orgWarn = console.warn; + const orgLog = console.log; + console.warn = (...args: any[]) => { + warnings.push(args.join(" ")); + }; + console.log = (...args: any[]) => { + logs.push(args.join(" ")); + }; + + try { + return { result: fn(), warnings, logs }; + } finally { + console.warn = orgWarn; + console.log = orgLog; + } +} + +function summaryOf(dir: string): any { + return JSON.parse(fs.readFileSync(path.join(dir, "coverage-summary.json"), "utf8")); +} + +/** + * Writing and reporting coverage: the browser and worker reporter, the node report that merges nyc's raw + * output, and the nyc invocation both share. nyc is run for real with the json-summary reporter, which + * writes a file rather than printing, so the results can be read back. + */ +describe("codecov reports", function () { + // Each report starts nyc in a child process + this.timeout(120000); + + let scratch: string; + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-reports-")); + }); + + afterEach(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("nycReport", () => { + it("works in the nearest folder with a package.json, as nyc itself would", () => { + const project = path.join(scratch, "project"); + fs.mkdirSync(path.join(project, "src", "deep"), { recursive: true }); + fs.writeFileSync(path.join(project, "package.json"), "{}", "utf8"); + + assert.equal(_nycCwd(path.join(project, "src", "deep")), project); + assert.equal(_nycCwd(project), project); + }); + + it("falls back to the folder it was given when no package.json is above it", () => { + const loose = path.join(scratch, "loose"); + fs.mkdirSync(loose); + + assert.equal(_nycCwd(loose), path.resolve(loose)); + }); + + it("defaults to the text reporter and repeats --reporter for each one", () => { + assert.deepEqual(_buildReporterArgs(), [ "--reporter", "text" ]); + assert.deepEqual(_buildReporterArgs([]), [ "--reporter", "text" ]); + assert.deepEqual(_buildReporterArgs([ "lcov", "json" ]), [ "--reporter", "lcov", "--reporter", "json" ]); + }); + + it("runs nyc report and returns its exit code", () => { + const tempDir = path.join(scratch, "raw"); + fs.mkdirSync(tempDir); + const data: any = {}; + data[path.join(scratch, "src", "a.js")] = covered(path.join(scratch, "src", "a.js"), 1); + fs.writeFileSync(path.join(tempDir, "out.json"), JSON.stringify(data), "utf8"); + + const status = _spawnNycReport(scratch, [ "--temp-dir", tempDir, "--report-dir", path.join(scratch, "report"), "--reporter", "json-summary" ], "test"); + + assert.equal(status, 0); + assert.equal(summaryOf(path.join(scratch, "report")).total.statements.pct, 50); + }); + + it("reports this project's files even when an outer nyc points NYC_CWD at another project", () => { + const tempDir = path.join(scratch, "raw"); + fs.mkdirSync(tempDir); + const data: any = {}; + data[path.join(scratch, "src", "a.js")] = covered(path.join(scratch, "src", "a.js"), 1); + fs.writeFileSync(path.join(tempDir, "out.json"), JSON.stringify(data), "utf8"); + + // Another project whose .nycrc would include none of this one's files + const outer = path.join(scratch, "outer"); + fs.mkdirSync(outer); + fs.writeFileSync(path.join(outer, "package.json"), "{}", "utf8"); + fs.writeFileSync(path.join(outer, ".nycrc"), JSON.stringify({ include: [ "nothing/**" ] }), "utf8"); + + const saved = process.env.NYC_CWD; + process.env.NYC_CWD = outer; + try { + assert.equal(_spawnNycReport(scratch, [ "--temp-dir", tempDir, "--report-dir", path.join(scratch, "report"), "--reporter", "json-summary" ], "test"), 0); + } finally { + if (saved === undefined) { + delete process.env.NYC_CWD; + } else { + process.env.NYC_CWD = saved; + } + } + + assert.equal(summaryOf(path.join(scratch, "report")).total.statements.pct, 50, "this project's file was reported"); + }); + + it("warns with the label when nyc report fails", () => { + const run = captureConsole(() => _spawnNycReport(scratch, [ "--temp-dir", scratch, "--reporter", "no-such-reporter-anywhere" ], "my label")); + + assert.notEqual(run.result, 0); + assert.includes(run.warnings.join("\n"), "nyc report failed for my label with exit code"); + }); + }); + + describe("NycCoverageReporter", () => { + it("writes the browser coverage without the embedded sources and reports it", () => { + const file = path.join(scratch, "src", "a.js"); + const data: any = {}; + data[file] = Object.assign(covered(file, 3), { inputSourceMap: { version: 3, sources: [ "a.ts" ], sourcesContent: [ "huge source" ], mappings: "AAAA" } }); + data[path.join(scratch, "src", "b.js")] = covered(path.join(scratch, "src", "b.js"), 0); + + const run = captureConsole(() => new NycCoverageReporter([ "json-summary" ]).write(data, scratch, "browser")); + + const written = JSON.parse(fs.readFileSync(path.join(scratch, "coverage", "browser", "coverage-final.json"), "utf8")); + assert.isUndefined(written[file].inputSourceMap.sourcesContent, "the sources are stripped"); + assert.deepEqual(written[file].inputSourceMap.sources, [ "a.ts" ], "the rest of the map is kept"); + assert.deepEqual(written[file].s, { 0: 3, 1: 0 }); + assert.includes(run.logs.join("\n"), "coverage-final.json"); + assert.isTrue(fs.existsSync(path.join(scratch, "coverage", "browser", "report", "coverage-summary.json")), "the report was generated"); + }); + + it("writes worker coverage to its own folder, with the text reporter by default", () => { + const reporter = new NycCoverageReporter(); + const data: any = {}; + data[path.join(scratch, "src", "w.js")] = covered(path.join(scratch, "src", "w.js"), 1); + + captureConsole(() => reporter.write(data, scratch, "worker")); + + assert.isTrue(fs.existsSync(path.join(scratch, "coverage", "worker", "coverage-final.json"))); + assert.isFalse(fs.existsSync(path.join(scratch, "coverage", "browser"))); + }); + + it("warns rather than throws when the coverage folder cannot be written", () => { + fs.writeFileSync(path.join(scratch, "coverage"), "a file where the folder should be", "utf8"); + + const run = captureConsole(() => new NycCoverageReporter([ "json-summary" ]).write({}, scratch, "browser")); + + assert.includes(run.warnings.join("\n"), "Failed to write browser coverage file"); + }); + }); + + describe("runCoverageReport", () => { + function writeNycOutput(dir: string): { real: string; empty: string } { + const real = path.join(scratch, "src", "real.js"); + const empty = path.join(scratch, "src", "types.js"); + const data: any = {}; + data[real] = covered(real, 2); + data[empty] = erased(empty); + + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "process-1.json"), JSON.stringify(data), "utf8"); + fs.writeFileSync(path.join(dir, "broken.json"), "{ not coverage", "utf8"); + fs.writeFileSync(path.join(dir, "notes.txt"), "ignored", "utf8"); + + return { real, empty }; + } + + it("warns and fails when there is no nyc output to report", () => { + const run = captureConsole(() => runCoverageReport(scratch, {})); + + assert.equal(run.result, 1); + assert.includes(run.warnings.join("\n"), "No nyc coverage data found at"); + }); + + it("reports erased files as uncovered by default", () => { + const files = writeNycOutput(path.join(scratch, ".nyc_output")); + + const run = captureConsole(() => runCoverageReport(scratch, { coverageReporters: [ "json-summary" ] })); + const summary = summaryOf(path.join(scratch, "coverage")); + + assert.equal(run.result, 0); + assert.includes(run.logs.join("\n"), "zero"); + assert.isObject(summary[files.real]); + assert.isObject(summary[files.empty], "the erased file is still listed"); + assert.equal(summary[files.real].statements.pct, 50); + }); + + it("counts erased files as covered in full mode and drops them in ignore mode", () => { + const files = writeNycOutput(path.join(scratch, "raw")); + + captureConsole(() => runCoverageReport(scratch, { nycOutputDir: "raw", erasedCoverageMode: "full", coverageReporters: [ "json-summary" ] })); + const full = summaryOf(path.join(scratch, "coverage")); + assert.equal(full[files.empty].statements.pct, 100); + + fs.rmSync(path.join(scratch, "coverage"), { recursive: true, force: true }); + captureConsole(() => runCoverageReport(scratch, { nycOutputDir: "raw", erasedCoverageMode: "ignore", coverageReporters: [ "json-summary" ] })); + const ignored = summaryOf(path.join(scratch, "coverage")); + assert.isUndefined(ignored[files.empty]); + assert.isObject(ignored[files.real]); + }); + + it("removes its temporary merge folder", () => { + writeNycOutput(path.join(scratch, ".nyc_output")); + const before = fs.readdirSync(os.tmpdir()).filter((f) => f.indexOf("tripwire-node-coverage-") === 0).length; + + captureConsole(() => runCoverageReport(scratch, { coverageReporters: [ "json-summary" ] })); + + const after = fs.readdirSync(os.tmpdir()).filter((f) => f.indexOf("tripwire-node-coverage-") === 0).length; + assert.equal(after, before); + }); + }); +}); diff --git a/internal/runner/test/src/compile/cacheStore.test.ts b/internal/runner/test/src/compile/cacheStore.test.ts index d424471..b2b284b 100644 --- a/internal/runner/test/src/compile/cacheStore.test.ts +++ b/internal/runner/test/src/compile/cacheStore.test.ts @@ -186,6 +186,24 @@ describe("compile cacheStore", () => { assert.isNull(readEmitCache(null, "key")); }); + it("should give up quietly when the cache directory cannot be created", () => { + const dir = _tempDir("blocked"); + try { + // A file where the cache directory would go + const blocked = path.join(dir, "cache"); + fs.writeFileSync(blocked, "not a directory", "utf8"); + const key = emitCacheKey("/p/a.ts", "let a = 1;", "fp"); + + writeEmitCache(blocked, key, "var a = 1;"); + writeProgramManifest(blocked, "/p/tsconfig.json", createProgramManifest("fp", [], [], [], false)); + + assert.isNull(readEmitCache(blocked, key)); + assert.equal(fs.readFileSync(blocked, "utf8"), "not a directory"); + } finally { + _rm(dir); + } + }); + it("should not leave temporary files behind", () => { const dir = _tempDir("notemp"); try { @@ -436,6 +454,15 @@ describe("compile cacheStore", () => { assert.isFalse(checkProgramManifest(manifest, "fp", roots, locks).current); }); + it("should name the lock file whose install changed", () => { + const recorded = createProgramManifest("fp", roots, roots, [ [ "package-lock.json", "1:1" ] ], true); + + const check = checkProgramManifest(recorded, "fp", roots, [ [ "package-lock.json", "2:2" ] ]); + + assert.isFalse(check.current); + assert.equal(check.reason, "an install changed how imports resolve (package-lock.json)"); + }); + it("should reject a null or unrecognised manifest", () => { assert.isFalse(checkProgramManifest(null, "fp", roots, []).current); assert.isFalse(checkProgramManifest({ v: 99 } as any, "fp", roots, []).current); diff --git a/internal/runner/test/src/compile/tsCompileProject.test.ts b/internal/runner/test/src/compile/tsCompileProject.test.ts new file mode 100644 index 0000000..cba22b2 --- /dev/null +++ b/internal/runner/test/src/compile/tsCompileProject.test.ts @@ -0,0 +1,497 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { createRequire } from "module"; +import { assert } from "@nevware21/tripwire"; +import { + compileTypeScript, getCompileCacheStatus, getProjectFileNames, invalidateTypeScriptProgramCache, loadTypescript, + registerTypeScriptHook, setGraphTypeCheck, transpileTypeScriptSource, transpileTypeScriptSourceWithMap +} from "../../../src/compile/tsCompile"; +import { getCompileProvider } from "../../../src/compile/provider"; +import { _isUserError } from "../../../src/internal/userError"; + +const _require = createRequire(__filename); +const _PROJECT_PREFIX = path.join(os.tmpdir(), "tripwire-tsproject-"); + +const TS_CONFIG = { compilerOptions: { target: "es2018", module: "commonjs", lib: [ "es2018" ], types: [] as string[] } }; + +// A const enum is only inlined when the file is emitted from a program that also holds the enum's +// declaration, so it tells a program emit apart from a file transpiled on its own. +const ENUMS = "export const enum Color {\n Red = 1,\n Green = 2\n}\n"; +const MAIN = "import { Color } from \"./enums\";\nexport const red: number = Color.Red;\n"; +const TYPE_ERROR = "export const count: number = \"not a number\";\n"; + +function _configWith(extra: any): any { + return { compilerOptions: TS_CONFIG.compilerOptions, ...extra }; +} + +function _caught(fn: () => any): any { + try { + fn(); + } catch (e) { + return e; + } + + return null; +} + +function _hasText(text: string, value: string): boolean { + return text.indexOf(value) !== -1; +} + +function _filesUnder(dir: string, matcher: (name: string) => boolean): string[] { + const found: string[] = []; + if (fs.existsSync(dir)) { + fs.readdirSync(dir, { withFileTypes: true }).forEach((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + found.push(..._filesUnder(full, matcher)); + } else if (matcher(entry.name)) { + found.push(full); + } + }); + } + + return found; +} + +/** + * Compiling real, throwaway TypeScript projects: the transpile entry points the browser and worker module + * graph uses, the project file list, the tsc check, and the node require hook with its compile cache. + */ +describe("tsCompile projects", function () { + // Every case builds at least one TypeScript program, and one runs tsc in a child process + this.timeout(120000); + + let projects: string[] = []; + + function makeProject(files: { [name: string]: string }, tsconfig?: any): string { + const dir = fs.mkdtempSync(_PROJECT_PREFIX); + + // A node_modules folder keeps the compile cache inside the project, so it is removed with it + fs.mkdirSync(path.join(dir, "node_modules")); + if (tsconfig !== null) { + fs.writeFileSync(path.join(dir, "tsconfig.json"), typeof tsconfig === "string" ? tsconfig : JSON.stringify(tsconfig || TS_CONFIG), "utf8"); + } + + Object.keys(files).forEach((name) => { + const target = path.join(dir, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, files[name], "utf8"); + }); + + projects.push(dir); + + return dir; + } + + afterEach(() => { + setGraphTypeCheck(false); + invalidateTypeScriptProgramCache(); + projects.forEach((dir) => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + projects = []; + }); + + describe("loadTypescript", () => { + it("loads the compiler, falling back to the runner's own install", () => { + const ts = loadTypescript(makeProject({})); + + assert.isFunction(ts.transpileModule); + assert.isString(ts.version); + }); + }); + + describe("getProjectFileNames", () => { + it("lists the project's files and keeps them until the program cache is invalidated", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const names = () => (getProjectFileNames("tsconfig.json", dir) || []).map((f) => path.basename(f)).sort(); + + assert.deepEqual(names(), [ "enums.ts", "main.ts" ]); + + fs.writeFileSync(path.join(dir, "later.ts"), "export {};\n", "utf8"); + assert.deepEqual(names(), [ "enums.ts", "main.ts" ], "the cached list is kept"); + + invalidateTypeScriptProgramCache(); + assert.deepEqual(names(), [ "enums.ts", "later.ts", "main.ts" ]); + }); + + it("returns null for a missing, unreadable or empty project", () => { + assert.isNull(getProjectFileNames("missing.json", makeProject({}))); + assert.isNull(getProjectFileNames("tsconfig.json", makeProject({}, "{"))); + assert.isNull(getProjectFileNames("tsconfig.json", makeProject({}, _configWith({ include: [ "nothing/**" ] })))); + }); + }); + + describe("compileTypeScript", () => { + it("type-checks a clean project with tsc", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + compileTypeScript("tsconfig.json", dir); + }); + + it("reports a missing project file as a user error", () => { + const dir = makeProject({}); + const failure = _caught(() => compileTypeScript("missing.json", dir)); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "TypeScript project file not found: " + path.join(dir, "missing.json")); + }); + }); + + describe("transpileTypeScriptSource", () => { + it("emits from the nearest tsconfig's program, inlining const enums from other project files", () => { + const dir = makeProject({ "enums.ts": ENUMS, "src/main.ts": MAIN.replace("./enums", "../enums") }); + + const output = transpileTypeScriptSource(path.join(dir, "src", "main.ts"), "", dir); + + assert.includes(output, "1 /* Color.Red */"); + assert.isFalse(_hasText(output, "require("), "the const enum import is erased"); + assert.isFalse(_hasText(output, "sourceMappingURL"), "no source map is asked for"); + }); + + it("uses an explicit project path relative to cwd", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, null); + fs.mkdirSync(path.join(dir, "config")); + fs.writeFileSync(path.join(dir, "config", "tsconfig.app.json"), JSON.stringify(_configWith({ files: [ "../enums.ts", "../main.ts" ] })), "utf8"); + + assert.includes(transpileTypeScriptSource(path.join(dir, "main.ts"), "", dir, "config/tsconfig.app.json"), "1 /* Color.Red */"); + }); + + it("keeps each file's emit until the program cache is invalidated", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + + const first = transpileTypeScriptSource(file, "", dir); + fs.writeFileSync(file, "export const red: number = 7;\n", "utf8"); + + assert.equal(transpileTypeScriptSource(file, "", dir), first, "the cached emit is returned"); + + invalidateTypeScriptProgramCache(); + assert.includes(transpileTypeScriptSource(file, "", dir), "exports.red = 7"); + }); + + it("adds a file the project does not include to its program, still inlining the project's const enums", () => { + const dir = makeProject({ "enums.ts": ENUMS, "extra.ts": MAIN }, _configWith({ files: [ "enums.ts" ] })); + + assert.includes(transpileTypeScriptSource(path.join(dir, "extra.ts"), "", dir), "1 /* Color.Red */"); + }); + + it("transpiles the given source on its own when the program has no such file", () => { + const dir = makeProject({ "enums.ts": ENUMS }); + const file = path.join(dir, "notOnDisk.ts"); + + const output = transpileTypeScriptSource(file, MAIN, dir); + assert.includes(output, "require(\"./enums\")"); + assert.includes(output, "Color.Red"); + + assert.equal(transpileTypeScriptSource(file, MAIN, dir), output, "the missing file is remembered, and the source transpiled again"); + }); + + it("transpiles the given source on its own when the project is missing, broken or empty", () => { + const check = (dir: string, projectPath?: string) => { + const output = transpileTypeScriptSource(path.join(dir, "main.ts"), MAIN, dir, projectPath); + assert.includes(output, "require(\"./enums\")", dir); + assert.isFalse(_hasText(output, "sourceMappingURL")); + }; + + check(makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }), "missing.json"); + check(makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, "{")); + check(makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, _configWith({ include: [ "nothing/**" ] }))); + }); + + it("transpiles the given source on its own when no tsconfig is found above the file", function () { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, null); + for (let current = dir; ; current = path.dirname(current)) { + if (fs.existsSync(path.join(current, "tsconfig.json"))) { + // A tsconfig above the temp folder would be found instead + this.skip(); + } + + if (path.dirname(current) === current) { + break; + } + } + + assert.includes(transpileTypeScriptSource(path.join(dir, "main.ts"), MAIN, dir), "require(\"./enums\")"); + }); + }); + + describe("transpileTypeScriptSourceWithMap", () => { + function inlineMap(output: string): any { + const parts = output.split("sourceMappingURL=data:application/json;base64,"); + assert.equal(parts.length, 2, "one inline source map"); + + return JSON.parse(Buffer.from(parts[1].trim(), "base64").toString("utf8")); + } + + it("embeds an inline source map holding the original source", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + const output = transpileTypeScriptSourceWithMap(path.join(dir, "main.ts"), "", dir); + + assert.includes(output, "1 /* Color.Red */"); + assert.includes(inlineMap(output).sourcesContent[0], "Color.Red"); + }); + + it("keeps a program of its own, apart from the one emitting without maps", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + + assert.isFalse(_hasText(transpileTypeScriptSource(file, "", dir), "sourceMappingURL")); + assert.includes(transpileTypeScriptSourceWithMap(file, "", dir), "sourceMappingURL"); + assert.isFalse(_hasText(transpileTypeScriptSource(file, "", dir), "sourceMappingURL")); + }); + + it("embeds the map in the fallback transpile as well", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + const output = transpileTypeScriptSourceWithMap(path.join(dir, "main.ts"), MAIN, dir, "missing.json"); + + assert.includes(output, "require(\"./enums\")"); + assert.includes(inlineMap(output).sourcesContent[0], "Color.Red"); + }); + }); + + describe("getCompileProvider", () => { + it("shares one provider backed by the TypeScript compiler", () => { + const provider = getCompileProvider(); + + assert.equal(getCompileProvider(), provider); + assert.equal(provider.compile, compileTypeScript); + assert.equal(provider.registerHook, registerTypeScriptHook); + }); + + it("transpiles with a source map only when one is asked for", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + + assert.isFalse(_hasText(getCompileProvider().transpile(file, "", dir), "sourceMappingURL")); + assert.includes(getCompileProvider().transpile(file, "", dir, true), "sourceMappingURL"); + assert.includes(getCompileProvider().transpile(file, MAIN, dir, true, "missing.json"), "require(\"./enums\")"); + }); + }); + + describe("graph type-check", () => { + it("checks the program it emits from, reporting a type error as a user error", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + setGraphTypeCheck(true); + + const failure = _caught(() => transpileTypeScriptSource(path.join(dir, "main.ts"), "", dir)); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "TypeScript compile failed with 1 error(s)"); + assert.includes(failure.message, "TS2322"); + assert.includes(failure.message, "bad.ts"); + }); + + it("does not check when it is turned off", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + + assert.includes(transpileTypeScriptSource(path.join(dir, "main.ts"), "", dir), "1 /* Color.Red */"); + }); + + it("checks a rebuilt program again once the program cache is invalidated", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const file = path.join(dir, "main.ts"); + setGraphTypeCheck(true); + + assert.includes(transpileTypeScriptSource(file, "", dir), "1 /* Color.Red */"); + + fs.writeFileSync(path.join(dir, "bad.ts"), TYPE_ERROR, "utf8"); + assert.includes(transpileTypeScriptSource(file, "", dir), "1 /* Color.Red */", "the checked program is reused"); + + invalidateTypeScriptProgramCache(); + assert.includes(_caught(() => transpileTypeScriptSource(file, "", dir)).message, "TypeScript compile failed with 1 error(s)"); + }); + }); + + describe("registerTypeScriptHook", () => { + const extensions: any = (_require as any).extensions; + + // Installs the hook for one session, then restores the hook the runner itself loads .ts files + // with. Re-assigning the earlier hook is also how nyc's require wrapper expects to be unwound. + function session(register: () => void, fn?: () => T): T { + const savedTs = extensions[".ts"]; + const savedTsx = extensions[".tsx"]; + + try { + register(); + + return fn ? fn() : undefined; + } finally { + extensions[".ts"] = savedTs; + if (savedTsx === undefined) { + delete extensions[".tsx"]; + } else { + extensions[".tsx"] = savedTsx; + } + + Object.keys(_require.cache).forEach((key) => { + if (key.indexOf(_PROJECT_PREFIX) === 0) { + delete _require.cache[key]; + } + }); + } + } + + function hookSession(dir: string, fn: () => T, checkDiagnostics?: boolean, overrides?: any, options?: any, projectPath?: string): T { + return session(() => registerTypeScriptHook(projectPath || "tsconfig.json", dir, checkDiagnostics, overrides, options), fn); + } + + function load(dir: string, name: string): any { + return _require(path.join(dir, name)); + } + + it("compiles required .ts and .tsx files through the project's program", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "view.tsx": "export const kind: string = \"tsx\";\n" }); + + hookSession(dir, () => { + assert.equal(load(dir, "main.ts").red, 1); + assert.equal(load(dir, "view.tsx").kind, "tsx"); + assert.isUndefined(_require.cache[path.join(dir, "enums.ts")], "the const enum import was erased"); + }); + + assert.deepEqual(getCompileCacheStatus(), { state: "rebuilt", checkSkipped: false, reason: "nothing cached for this project yet" }); + }); + + it("reuses the cached compile while nothing changes, and rebuilds when a file does", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => load(dir, "main.ts")); + + hookSession(dir, () => { + assert.deepEqual(getCompileCacheStatus(), { state: "reused", checkSkipped: false, reason: "" }); + assert.equal(load(dir, "main.ts").red, 1, "loaded from the emit cache"); + }); + + fs.writeFileSync(path.join(dir, "enums.ts"), ENUMS.replace("Red = 1", "Red = 5"), "utf8"); + hookSession(dir, () => { + const status = getCompileCacheStatus(); + assert.equal(status.state, "rebuilt"); + assert.includes(status.reason, "enums.ts changed"); + assert.equal(load(dir, "main.ts").red, 5); + }); + }); + + it("type-checks the project first when asked, reporting a type error as a user error", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + + const failure = _caught(() => hookSession(dir, () => null, true)); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "TypeScript compile failed with 1 error(s)"); + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), false); + }); + + it("checks only the files the run reaches under the run scope", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "bad.ts": TYPE_ERROR }); + const scoped = { typeCheckScope: "run", scopeFiles: [ path.join(dir, "main.ts") ] }; + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), true, undefined, scoped); + assert.isNotNull(_caught(() => hookSession(dir, () => null, true)), "the whole project still fails"); + }); + + it("skips the type-check when the cached compile was already checked", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => load(dir, "main.ts"), false); + + hookSession(dir, () => load(dir, "main.ts"), true); + assert.deepEqual(getCompileCacheStatus(), { state: "reused", checkSkipped: false, reason: "the cached compile came from a run that did not type-check" }); + + hookSession(dir, () => load(dir, "main.ts"), true); + assert.deepEqual(getCompileCacheStatus(), { state: "reused", checkSkipped: true, reason: "" }); + }); + + it("applies the tsconfig overrides", () => { + const dir = makeProject({ "loose.ts": "export function echo(value) {\n return value;\n}\n" }); + + hookSession(dir, () => assert.equal(load(dir, "loose.ts").echo(3), 3), true); + + const failure = _caught(() => hookSession(dir, () => null, true, { noImplicitAny: true })); + assert.includes(failure.message, "TS7006"); + }); + + it("keeps incremental build info for a checked run, even when the check fails", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + const buildInfo = () => _filesUnder(path.join(dir, "node_modules"), (name) => /\.tsbuildinfo$/.test(name)); + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), true, undefined, { incremental: true }); + assert.equal(buildInfo().length, 1); + const emitted = _filesUnder(dir, (name) => /\.js$/.test(name)).filter((file) => file.indexOf(path.join(dir, "node_modules")) !== 0); + assert.equal(emitted.length, 0, "no .js is written next to the sources"); + + fs.writeFileSync(path.join(dir, "bad.ts"), TYPE_ERROR, "utf8"); + const failure = _caught(() => hookSession(dir, () => null, true, undefined, { incremental: true })); + assert.includes(failure.message, "TypeScript compile failed with 1 error(s)"); + assert.equal(buildInfo().length, 2, "the new root set keeps its own state"); + }); + + it("transpiles each file on its own when isolated, reusing the emit cache", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => { + assert.equal(load(dir, "main.ts").red, 1); + assert.isObject(_require.cache[path.join(dir, "enums.ts")], "the preserved enum is required at runtime"); + }, false, undefined, { isolatedTranspile: true }); + + const status = getCompileCacheStatus(); + assert.equal(status.state, "isolated"); + assert.includes(status.reason, "each file is compiled on its own"); + + const cached = _filesUnder(path.join(dir, "node_modules"), () => true).length; + assert.isAbove(cached, 0, "the emitted files are cached"); + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), false, undefined, { isolatedTranspile: true }); + assert.equal(_filesUnder(path.join(dir, "node_modules"), () => true).length, cached, "the second run only reads the cache"); + }); + + it("runs without the compile cache when it is turned off", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }); + + hookSession(dir, () => assert.equal(load(dir, "main.ts").red, 1), false, undefined, { emitCache: false }); + + assert.deepEqual(getCompileCacheStatus(), { state: "off", checkSkipped: false, reason: "the compile cache is disabled" }); + assert.equal(_filesUnder(path.join(dir, "node_modules"), () => true).length, 0); + }); + + it("adds a required file the project does not include to its program", () => { + const dir = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN, "extra.ts": MAIN.replace("red", "extra") }, _configWith({ files: [ "enums.ts", "main.ts" ] })); + + hookSession(dir, () => { + assert.equal(load(dir, "main.ts").red, 1); + assert.equal(load(dir, "extra.ts").extra, 1); + }); + + hookSession(dir, () => assert.equal(load(dir, "extra.ts").extra, 1)); + assert.equal(getCompileCacheStatus().state, "reused", "the added file was recorded with the program"); + }); + + it("transpiles a file on its own when the program will not emit it", () => { + const dir = makeProject({ + "enums.ts": ENUMS, + "loose.ts": "const count: number = \"text\";\nexport const value = count;\n" + }, _configWith({ compilerOptions: { ...TS_CONFIG.compilerOptions, noEmitOnError: true } })); + + hookSession(dir, () => assert.equal(load(dir, "loose.ts").value, "text")); + }); + + it("compiles with its default options when the project file is missing or broken", () => { + const missing = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, null); + const broken = makeProject({ "enums.ts": ENUMS, "main.ts": MAIN }, "{"); + + hookSession(missing, () => assert.equal(load(missing, "main.ts").red, 1)); + hookSession(broken, () => assert.equal(load(broken, "main.ts").red, 1)); + }); + }); +}); diff --git a/internal/runner/test/src/config/loader.test.ts b/internal/runner/test/src/config/loader.test.ts index dbb223c..37eb9f5 100644 --- a/internal/runner/test/src/config/loader.test.ts +++ b/internal/runner/test/src/config/loader.test.ts @@ -9,6 +9,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { createRequire } from "module"; import { assert, afterAll as selfAfterAll, beforeAll as selfBeforeAll } from "@nevware21/tripwire"; import { DefaultConfigLoader, loadConfig, mergeConfigs } from "../../../src/config/loader"; import { ILoadedConfig } from "../../../src/interfaces/ILoadedConfig"; @@ -172,6 +173,75 @@ describe("config loader", () => { } }); + it("should load a config written as JavaScript, in a .js file or in an rc file", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-js-")); + try { + fs.mkdirSync(path.join(dir, "inner")); + fs.writeFileSync(path.join(dir, "tripwire.config.js"), "module.exports = { timeout: 7 };\n", "utf8"); + fs.writeFileSync(path.join(dir, "inner", ".tripwirerc"), "module.exports = { default: { slow: 9 } };\n", "utf8"); + + const found = new DefaultConfigLoader().load(path.join(dir, "inner")).filter((entry) => entry.filePath.indexOf(dir) === 0); + + assert.deepEqual(found.map((entry) => entry.config), [ { timeout: 7 }, { slow: 9 } ]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("should throw a message naming the file when a JavaScript config fails to load", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-jsbad-")); + try { + const filePath = path.join(dir, "tripwire.config.js"); + fs.writeFileSync(filePath, "throw new Error(\"bad config\");\n", "utf8"); + + assert.throws(() => { + new DefaultConfigLoader().load(dir); + }, "Failed to load tripwire config file '" + filePath + "': bad config"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + describe("home directory", () => { + const realOs = createRequire(__filename)("os"); + const orgHomedir = realOs.homedir; + let home = ""; + + selfBeforeAll(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-home-")); + }); + + selfAfterAll(() => { + realOs.homedir = orgHomedir; + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("should read the first config in the user's home, ahead of the project's", () => { + fs.mkdirSync(path.join(home, ".config", "tripwire"), { recursive: true }); + fs.writeFileSync(path.join(home, ".config", "tripwire", "tripwire.config.json"), JSON.stringify({ timeout: 11 }), "utf8"); + realOs.homedir = () => home; + try { + const found = new DefaultConfigLoader().load(root); + + assert.equal(found[0].filePath, path.join(home, ".config", "tripwire", "tripwire.config.json")); + assert.deepEqual(found[0].config, { timeout: 11 }); + } finally { + realOs.homedir = orgHomedir; + } + }); + + it("should look in no home when there is none", () => { + realOs.homedir = () => ""; + try { + const found = new DefaultConfigLoader().load(root); + + assert.isTrue(found.every((entry) => entry.filePath.indexOf(home) !== 0)); + } finally { + realOs.homedir = orgHomedir; + } + }); + }); + it("should prefer the higher-priority file name when several sit in one directory", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cfg-names-")); try { diff --git a/internal/runner/test/src/execute/assets.test.ts b/internal/runner/test/src/execute/assets.test.ts new file mode 100644 index 0000000..4eacce7 --- /dev/null +++ b/internal/runner/test/src/execute/assets.test.ts @@ -0,0 +1,174 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { + _getAssetFile, _getInlineMapAsset, _getRunnerBinAsset, _getSourceAsset, _getVendorAsset, _normalizeAssetUrl +} from "../../../src/execute/assets"; +import { _loadRunnerWorkerScript } from "../../../src/execute/worker"; + +// The asset helpers look their files up next to their own module, which is this folder when the +// tests run from source +const EXECUTE_DIR = path.resolve(__dirname, "../../../src/execute"); + +/** + * The files the browser runner's server hands out besides the test modules: the runner's own bundles + * and maps, the UX sources DevTools asks for, and vendor files from node_modules. + */ +describe("execute assets", () => { + let scratch: string; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-assets-")); + }); + + afterEach(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("_normalizeAssetUrl", () => { + it("uses forward slashes and drops the query string", () => { + assert.equal(_normalizeAssetUrl("\\src\\a.ts?v=1&x=2"), "/src/a.ts"); + assert.equal(_normalizeAssetUrl("/runner-client.js"), "/runner-client.js"); + }); + + it("treats a missing url as empty", () => { + assert.equal(_normalizeAssetUrl(""), ""); + assert.equal(_normalizeAssetUrl(undefined as any), ""); + }); + }); + + describe("_getAssetFile", () => { + it("reads a runner bundle from the assets folder as JavaScript", () => { + const file = write("runner-client.js", "var client = 1;"); + + const asset = _getAssetFile("/" + path.relative(path.join(EXECUTE_DIR, "assets"), file), false); + + assert.equal(asset.contentType, "application/javascript; charset=utf-8"); + assert.equal(asset.content.toString("utf8"), "var client = 1;"); + }); + + it("reads a map from the assets map folder as JSON", () => { + const file = write("runner-client.js.map", "{\"version\":3}"); + + const asset = _getInlineMapAsset(path.relative(path.join(EXECUTE_DIR, "assets", "map"), file)); + + assert.equal(asset.contentType, "application/json; charset=utf-8"); + assert.equal(asset.content.toString("utf8"), "{\"version\":3}"); + }); + + it("returns undefined when the runner was not built with its assets", () => { + assert.isUndefined(_getAssetFile("no-such-runner-asset.js", false)); + assert.isUndefined(_getInlineMapAsset("no-such-runner-asset.js.map")); + }); + }); + + describe("_getRunnerBinAsset", () => { + it("reads a file next to the runner bundle as JSON", () => { + const file = write("tripwire.js.map", "{\"version\":3,\"sources\":[]}"); + + const asset = _getRunnerBinAsset(path.relative(EXECUTE_DIR, file)); + + assert.equal(asset.contentType, "application/json; charset=utf-8"); + assert.deepEqual(JSON.parse(asset.content.toString("utf8")), { version: 3, sources: [] }); + }); + + it("returns undefined when there is no such file", () => { + assert.isUndefined(_getRunnerBinAsset("/no-such-runner-asset.js.map")); + }); + }); + + describe("_getSourceAsset", () => { + it("serves the runner's UX sources with a content type from their extension", () => { + const types: { [name: string]: string } = { + "browser/ui/client.ts": "application/typescript; charset=utf-8", + "browser/resources/runner.css": "text/css; charset=utf-8", + "browser/resources/runner.html": "text/html; charset=utf-8" + }; + + Object.keys(types).forEach((name) => { + const asset = _getSourceAsset(name); + assert.equal(asset.contentType, types[name], name); + assert.equal(asset.content.toString("utf8"), fs.readFileSync(path.resolve(EXECUTE_DIR, "../ux", name), "utf8"), name); + }); + + assert.equal(_getSourceAsset("\\browser\\ui\\client.ts").contentType, "application/typescript; charset=utf-8", "backslashes and a leading slash"); + }); + + it("serves other files as plain text", () => { + assert.equal(_getSourceAsset("images/favicon.ico").contentType, "text/plain; charset=utf-8"); + }); + + it("falls back to a runner/src/ux folder under the working directory", () => { + write("runner/src/ux/extra/fixture.js", "var a;"); + write("runner/src/ux/extra/fixture.json", "{}"); + write("runner/src/ux/extra/fixture.tsx", "export {};"); + + const orgCwd = process.cwd(); + process.chdir(scratch); + try { + assert.equal(_getSourceAsset("extra/fixture.js").contentType, "application/javascript; charset=utf-8"); + assert.equal(_getSourceAsset("extra/fixture.json").contentType, "application/json; charset=utf-8"); + assert.equal(_getSourceAsset("extra/fixture.tsx").contentType, "application/typescript; charset=utf-8"); + } finally { + process.chdir(orgCwd); + } + }); + + it("refuses empty, parent and drive relative paths, and returns undefined for a missing file", () => { + assert.isUndefined(_getSourceAsset("")); + assert.isUndefined(_getSourceAsset("/")); + assert.isUndefined(_getSourceAsset("../execute/assets.ts")); + assert.isUndefined(_getSourceAsset("browser/../../execute/assets.ts")); + assert.isUndefined(_getSourceAsset("C:/Windows/win.ini")); + assert.isUndefined(_getSourceAsset("browser/ui/no-such-file.ts")); + }); + }); + + describe("_getVendorAsset", () => { + it("picks the content type from the file's extension", () => { + const types: { [ext: string]: string } = { + ".js": "application/javascript; charset=utf-8", + ".MJS": "application/javascript; charset=utf-8", + ".cjs": "application/javascript; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".ts": "text/x-typescript; charset=utf-8", + ".tsx": "text/x-typescript; charset=utf-8", + ".wasm": "application/octet-stream" + }; + + Object.keys(types).forEach((ext) => { + const asset = _getVendorAsset(write("vendor" + ext, "content" + ext)); + assert.equal(asset.contentType, types[ext], ext); + assert.equal(asset.content.toString("utf8"), "content" + ext); + }); + }); + + it("returns undefined for a missing file", () => { + assert.isUndefined(_getVendorAsset(path.join(scratch, "missing.js"))); + }); + }); + + describe("_loadRunnerWorkerScript", () => { + it("reports a runner built without its worker bundle", () => { + assert.throws(() => _loadRunnerWorkerScript(scratch, false), "Missing runner worker asset: runner-worker.js"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/browserRunner.test.ts b/internal/runner/test/src/execute/browserRunner.test.ts new file mode 100644 index 0000000..5034bfb --- /dev/null +++ b/internal/runner/test/src/execute/browserRunner.test.ts @@ -0,0 +1,475 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as http from "http"; +import * as os from "os"; +import * as path from "path"; +import { createRequire } from "module"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { IBrowserWorkerModuleGraph, IGraphBuilders, _runInteractiveBrowserTarget } from "../../../src/execute/browser"; +import { _startBrowserRunnerSession, IBrowserRunnerSession } from "../../../src/execute/browserRunner"; +import { IServer } from "../../../src/interfaces/IServer"; + +interface IResponse { + status: number; + headers: http.IncomingHttpHeaders; + raw: Buffer; + body: string; +} + +function send(server: IServer, method: string, reqPath: string, body?: string): Promise { + const target = new URL(server.url); + + return new Promise((resolve, reject) => { + const req = http.request({ + host: target.hostname, + port: target.port, + method: method, + path: reqPath, + agent: false, + headers: body !== undefined ? { "content-type": "application/json", "content-length": Buffer.byteLength(body) } : undefined + }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const raw = Buffer.concat(chunks); + resolve({ status: res.statusCode, headers: res.headers, raw: raw, body: raw.toString("utf8") }); + }); + res.on("error", reject); + }); + + req.on("error", reject); + req.end(body); + }); +} + +function inlineMap(map: any): string { + return "\n//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify(map)).toString("base64"); +} + +/** + * The interactive browser and worker runner: a real server on loopback, serving the runner pages, the + * compiled module graph (bundled or one asset per module), the run payload, and the results the page + * reports back. + */ +describe("execute browserRunner", function () { + this.timeout(60000); + + let scratch: string; + let logs: string[]; + let orgLog: typeof console.log; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + // An entry test requiring a helper with a source map, and a vendor file served from disk. Module + // ids use forward slashes, as the graph builder writes them. + function makeGraph(): IBrowserWorkerModuleGraph { + const entry = write("test/a.test.js", "").split(path.sep).join("/"); + const helper = write("src/helper.js", "module.exports = 1;\n").split(path.sep).join("/"); + const vendor = write("node_modules/vendor/index.js", "module.exports = \"vendor\";\n"); + const modules: Record = {}; + const moduleUrls: Record = {}; + + modules[entry] = "const helper = require(" + JSON.stringify(helper) + ");\nexports.a = helper;\n"; + modules[helper] = "module.exports = 1;\n" + inlineMap({ version: 3, sources: [ "helper.ts" ], sourcesContent: [ "export = 1;" ], names: [], mappings: "AAAA" }); + moduleUrls[entry] = "/test/a.test.js"; + moduleUrls[helper] = "/src/helper.js"; + + return { + modules: modules, + entryIds: [ entry ], + tripwireModuleId: "/__tripwire__/tripwire.js", + coverageOnlyIds: [], + moduleUrls: moduleUrls, + rawAssets: { "/node_modules/vendor/index.js": vendor }, + elapsedMs: 1 + }; + } + + function builders(graph: IBrowserWorkerModuleGraph, watch?: boolean): IGraphBuilders & { builds: string[] } { + const builds: string[] = []; + + return { + builds: builds, + resolveEntryFiles: () => graph.entryIds.slice(0), + buildGraph: (target) => { + builds.push(target); + return graph; + }, + watch: !!watch + }; + } + + // Runs one session with the console captured, always stopping its server + async function withSession(parsed: any, graphBuilders: IGraphBuilders, fn: (session: IBrowserRunnerSession) => Promise): Promise { + const session = await _startBrowserRunnerSession(graphBuilders, Object.assign({ bindAddress: "127.0.0.1" }, parsed)); + try { + await fn(session); + } finally { + await Promise.resolve(session.server.stop()); + session.dispose(); + } + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-browser-runner-")); + logs = []; + orgLog = console.log; + console.log = (...args: any[]) => { + logs.push(replaceAnsi(args.join(" "), "")); + }; + }); + + afterEach(() => { + console.log = orgLog; + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("_startBrowserRunnerSession", () => { + it("serves the runner page for each target, isolated from other origins", async () => { + await withSession({ browserStyle: "slate" }, builders(makeGraph()), async (session) => { + const home = await send(session.server, "GET", "/"); + const browser = await send(session.server, "GET", "/__tripwire__/browser"); + const worker = await send(session.server, "GET", "/__tripwire__/worker"); + + assert.includes(home.body, "Browser", "the home page links to each runner"); + assert.equal(browser.status, 200); + assert.equal(browser.headers["content-type"], "text/html; charset=utf-8"); + assert.equal(browser.headers["cross-origin-opener-policy"], "same-origin"); + assert.includes(browser.body, "Tripwire Browser Runner"); + assert.includes(browser.body, "style-slate"); + assert.includes(worker.body, "Tripwire Worker Runner"); + assert.includes(logs.join("\n"), "Server URL: " + session.server.url); + }); + }); + + it("serves the test frame, loading the bundle only for the browser target", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + const browser = await send(session.server, "GET", "/__tripwire__/test-frame"); + const worker = await send(session.server, "GET", "/__tripwire__/test-frame?target=worker"); + + assert.equal(browser.headers["cross-origin-opener-policy"], "same-origin"); + assert.includes(browser.body, ""); + assert.includes(browser.body, "/__tripwire__/runner-client.js"); + assert.isFalse(worker.body.indexOf("bundle-browser.js") !== -1); + }); + }); + + it("serves the modules as one bundle per target, with the merged map", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "GET", "/__tripwire__/bundle-browser.js")).status, 404, "not built until the page is used"); + await send(session.server, "GET", "/__tripwire__/browser"); + const bundle = await send(session.server, "GET", "/__tripwire__/bundle-browser.js"); + const map = await send(session.server, "GET", "/__tripwire__/bundle-browser.js.map"); + + assert.equal(bundle.headers["content-type"], "application/javascript; charset=utf-8"); + assert.includes(bundle.body, "__tripwireDefine__(\"/test/a.test.js\""); + assert.includes(bundle.body, "require(\"/src/helper.js\")"); + assert.equal(map.headers["content-type"], "application/json; charset=utf-8"); + assert.deepEqual(JSON.parse(map.body).sources, [ "/src/helper.ts" ]); + + assert.equal((await send(session.server, "GET", "/__tripwire__/bundle-worker.js")).status, 404, "not built until the worker page is used"); + await send(session.server, "GET", "/__tripwire__/worker"); + assert.includes((await send(session.server, "GET", "/__tripwire__/bundle-worker.js")).body, "__tripwireDefine__"); + }); + }); + + it("serves each module on its own when bundling is off", async () => { + await withSession({ bundle: false }, builders(makeGraph()), async (session) => { + const frame = await send(session.server, "GET", "/__tripwire__/test-frame"); + const entry = await send(session.server, "GET", "/test/a.test.js"); + const map = await send(session.server, "GET", "/src/helper.js.map"); + const payload = JSON.parse((await send(session.server, "GET", "/api/payload-worker")).body); + + assert.includes(frame.body, ""); + assert.includes(frame.body, ""); + assert.includes(entry.body, "__tripwireDefine__(\"/test/a.test.js\""); + assert.equal(map.headers["content-type"], "application/json; charset=utf-8"); + assert.deepEqual(payload.moduleUrls, makeGraph().moduleUrls); + }); + }); + + it("serves the run payload for each target", async () => { + await withSession({ timeout: 1234, reporter: [ "dot" ] }, builders(makeGraph()), async (session) => { + const payload = JSON.parse((await send(session.server, "GET", "/api/payload")).body); + const browser = JSON.parse((await send(session.server, "GET", "/api/payload-browser")).body); + const worker = JSON.parse((await send(session.server, "GET", "/api/payload-worker")).body); + + assert.deepEqual(payload.entryIds, [ "/test/a.test.js" ]); + assert.equal(payload.settings.timeout, 1234); + assert.deepEqual(payload.settings.reporter, [ "dot" ]); + assert.deepEqual(payload.settings.server, { url: session.server.url, port: session.server.port, isRunning: true }); + assert.deepEqual(browser, payload); + assert.deepEqual(worker.moduleUrls, { bundle: "/__tripwire__/bundle-worker.js" }); + assert.equal((await send(session.server, "POST", "/api/payload", "{}")).status, 404); + }); + }); + + it("reports each target's build generation for the page's freshness check", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.deepEqual(JSON.parse((await send(session.server, "GET", "/__tripwire__/check-fresh?target=worker")).body), { generation: 0 }); + + await send(session.server, "GET", "/__tripwire__/browser"); + assert.deepEqual(JSON.parse((await send(session.server, "GET", "/__tripwire__/check-fresh")).body), { generation: 1 }); + }); + }); + + it("redirects the bare runtime folder home and refuses anything but GET", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + const redirect = await send(session.server, "GET", "/__tripwire__/"); + const post = await send(session.server, "POST", "/__tripwire__/browser", "{}"); + + assert.equal(redirect.status, 302); + assert.equal(redirect.headers.location, "/"); + assert.equal(post.status, 405); + }); + }); + + it("serves vendor files from disk and the runner's icon", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + // The registry is only filled once a target is built + await send(session.server, "GET", "/__tripwire__/browser"); + const vendor = await send(session.server, "GET", "/node_modules/vendor/index.js"); + const icon = await send(session.server, "GET", "/favicon.ico"); + + assert.equal(vendor.headers["content-type"], "application/javascript; charset=utf-8"); + assert.equal(vendor.body, "module.exports = \"vendor\";\n"); + assert.equal(icon.headers["content-type"], "image/x-icon"); + assert.isTrue(icon.raw.equals(fs.readFileSync(path.resolve(__dirname, "../../../src/ux/images/favicon.ico")))); + }); + }); + + it("falls through to the server root for runner files this build does not have", async () => { + write("served/hello.txt", "hello"); + + await withSession({ serverRoot: scratch }, builders(makeGraph()), async (session) => { + const runtime = await Promise.all([ "/runner-client.js", "/runner-worker.js", "/runner-client.js.map", "/runner-worker.js.map", "/tripwire.js.map", "/src/missing.ts" ] + .map((url) => send(session.server, "GET", url))); + const listing = await send(session.server, "GET", "/served/"); + const file = await send(session.server, "GET", "/served/hello.txt"); + + runtime.forEach((res) => assert.equal(res.status, 404)); + assert.includes(listing.body, "hello.txt"); + assert.equal(file.body, "hello"); + }); + }); + + it("logs the messages the page sends", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "POST", "/api/log", JSON.stringify({ entries: [ { level: "info", message: "first" }, { message: "second" } ] }))).status, 204); + await send(session.server, "POST", "/api/log", "not json"); + + const logged = logs.join("\n"); + assert.includes(logged, "first"); + assert.includes(logged, "second"); + assert.includes(logged, "not json"); + }); + }); + + it("fails the worker script request when the runner was built without it", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "GET", "/api/worker-script")).status, 500); + assert.equal((await send(session.server, "POST", "/api/worker-script", "{}")).status, 404); + }); + }); + + it("resolves the next completion with the results the page reports", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + const completion = session.nextCompletion(); + const res = await send(session.server, "POST", "/api/result", JSON.stringify({ summary: { passed: 2 }, coverage: { a: 1 } })); + + assert.equal(res.status, 202); + assert.deepEqual(await completion, { summary: { passed: 2 }, coverage: { a: 1 } }); + }); + }); + + it("rejects the completion for a failed run or a missing summary, and ignores results nobody waits for", async () => { + await withSession({}, builders(makeGraph()), async (session) => { + await send(session.server, "POST", "/api/result", JSON.stringify({ summary: { ignored: true } })); + + const failures: string[] = []; + for (const body of [ JSON.stringify({ error: "the page crashed" }), "{}", "not json" ]) { + // Settled as soon as the result arrives, so handled before it is sent + const outcome = session.nextCompletion().then(() => "resolved", (e) => e.message); + await send(session.server, "POST", "/api/result", body); + failures.push(await outcome); + } + + assert.deepEqual(failures, [ "the page crashed", "No summary returned from browser runner UI.", "not json" ]); + assert.equal((await send(session.server, "GET", "/api/result")).status, 404); + }); + }); + + it("builds a target ahead of time, and lists the files it would watch", async () => { + const graph = makeGraph(); + const graphBuilders = builders(graph, true); + + await withSession({}, graphBuilders, async (session) => { + assert.deepEqual(session.getWatchFiles("worker"), []); + session.prepare("worker"); + + assert.deepEqual(graphBuilders.builds, [ "worker" ]); + assert.sameMembers(session.getWatchFiles("worker"), Object.keys(graph.modules).filter((id) => id.indexOf("/__tripwire__/") !== 0)); + }); + }); + + it("uses a custom UX provider, framing the tests itself when the provider does not", async () => { + const provider = write("ux-provider.js", [ + "exports.createBrowserRunnerHtml = (target, style) => '';", + "exports.createFsDirListingHtml = () => '';" + ].join("\n")); + + await withSession({ uxProvider: provider }, builders(makeGraph()), async (session) => { + assert.equal((await send(session.server, "GET", "/__tripwire__/browser")).body, ""); + + const frame = (await send(session.server, "GET", "/__tripwire__/test-frame")).body; + assert.includes(frame, "data-runner-target=\"browser\""); + assert.includes(frame, ""); + }); + }); + + it("instruments the modules for coverage when asked", async () => { + const orgCwd = process.cwd(); + const graph = makeGraph(); + let session: IBrowserRunnerSession; + + // The session takes the project folder, and so the coverage scope, from where it starts + process.chdir(scratch); + try { + session = await _startBrowserRunnerSession(builders(graph), { bindAddress: "127.0.0.1", coverage: true }); + } finally { + process.chdir(orgCwd); + } + + try { + await send(session.server, "GET", "/__tripwire__/browser"); + const bundle = (await send(session.server, "GET", "/__tripwire__/bundle-browser.js")).body; + assert.includes(bundle, "__coverage__"); + } finally { + await Promise.resolve(session.server.stop()); + session.dispose(); + } + }); + }); + + describe("_runInteractiveBrowserTarget", () => { + // Starts a run, then reports `body` the way the page would. The run settles as soon as the + // report arrives, so its outcome is captured before the report is sent. + async function runAndReport(target: string, body: string, launchMode: string = "print"): Promise<{ outcome: Promise<{ value: any, error: any }>, url: string }> { + const firstLog = logs.length; + const outcome = _runInteractiveBrowserTarget(target, builders(makeGraph()), { bindAddress: "127.0.0.1" }, launchMode) + .then((value) => ({ value: value, error: null as any }), (error) => ({ value: null as any, error: error })); + + let url = ""; + for (let lp = 0; lp < 200 && !url; lp++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + const line = logs.slice(firstLog).filter((msg) => msg.indexOf("Runner URL: ") === 0)[0]; + url = line ? line.substring("Runner URL: ".length) : ""; + } + + assert.isTrue(!!url, "the runner url was printed"); + const origin = new URL(url); + await send({ url: origin.origin + "/" } as any, "POST", "/api/result", body); + + return { outcome, url }; + } + + it("prints the runner url and resolves with the summary the page reports", async () => { + const { outcome, url } = await runAndReport("worker", JSON.stringify({ summary: { passed: 1 } })); + const settled = await outcome; + const result = settled.value; + + assert.isNull(settled.error); + assert.isTrue(/^http:\/\/127\.0\.0\.1:\d+\/__tripwire__\/worker$/.test(url), url); + assert.deepEqual(result.summary, { passed: 1 }); + assert.equal(result.reportMs, 0); + assert.includes(logs.join("\n"), "Open this URL in your browser to start the run: " + url); + }); + + describe("opening the system browser", () => { + const childProcess = createRequire(__filename)("child_process"); + const orgSpawn = childProcess.spawn; + const orgPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + let spawned: any[]; + + function onPlatform(platform: string): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true, enumerable: true, writable: false }); + } + + beforeEach(() => { + spawned = []; + childProcess.spawn = (command: string, args: string[], options: any) => { + const call = { command: command, args: args, options: options, unref: 0 }; + spawned.push(call); + + return { unref: () => call.unref++ }; + }; + }); + + afterEach(() => { + childProcess.spawn = orgSpawn; + Object.defineProperty(process, "platform", orgPlatform); + }); + + async function openOn(platform: string): Promise { + onPlatform(platform); + const { outcome, url } = await runAndReport("browser", JSON.stringify({ summary: { passed: 1 } }), "open"); + assert.isNull((await outcome).error); + + return url; + } + + it("uses start on Windows, without a console window", async () => { + const url = await openOn("win32"); + + assert.equal(spawned.length, 1); + assert.equal(spawned[0].command, "cmd"); + assert.deepEqual(spawned[0].args, [ "/c", "start", "", url ]); + assert.isTrue(spawned[0].options.windowsHide); + assert.isTrue(spawned[0].options.detached); + assert.equal(spawned[0].unref, 1, "the CLI does not wait for the browser"); + assert.includes(logs.join("\n"), "Opening browser via OS default handler..."); + assert.includes(logs.join("\n"), "If the browser did not open, use this URL: " + url); + }); + + it("uses open on macOS and xdg-open elsewhere", async () => { + const macUrl = await openOn("darwin"); + const linuxUrl = await openOn("linux"); + + assert.deepEqual(spawned.map((call) => [ call.command, call.args ]), [ [ "open", [ macUrl ] ], [ "xdg-open", [ linuxUrl ] ] ]); + }); + + it("prints the url to open when the browser cannot be started", async () => { + childProcess.spawn = () => { + throw new Error("spawn ENOENT"); + }; + + const url = await openOn("linux"); + + assert.includes(logs.join("\n"), "Unable to auto-open browser. Open this URL manually: " + url); + }); + }); + + it("stops the server and passes the failure on when the page reports one", async () => { + const { outcome, url } = await runAndReport("browser", JSON.stringify({ error: "the page crashed" })); + const failure = (await outcome).error; + + assert.equal(failure && failure.message, "the page crashed"); + assert.includes(url, "/__tripwire__/browser"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/compile.test.ts b/internal/runner/test/src/execute/compile.test.ts new file mode 100644 index 0000000..7d9ae76 --- /dev/null +++ b/internal/runner/test/src/execute/compile.test.ts @@ -0,0 +1,183 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { getCompileProvider } from "../../../src/compile/provider"; +import { getCompileCacheStatus, transpileTypeScriptSource } from "../../../src/compile/tsCompile"; +import { + _getCompileCacheStatus, _getCompileProvider, _getProjectFileNames, _invalidateTypeScriptTranspileCache, _registerTypeScriptHook, + _setGraphTypeCheck +} from "../../../src/execute/compile"; +import { _resolveBrowserDependency } from "../../../src/execute/browser"; +import { _isUserError } from "../../../src/internal/userError"; + +/** + * The execute layer's view of compiling: picking the compile provider, and the wrappers the run uses + * around the TypeScript compiler. Also how a browser or worker module's imports are found on disk. + */ +describe("execute compile", function () { + // Some cases build a small TypeScript program + this.timeout(60000); + + let scratch: string; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-execute-compile-")); + }); + + afterEach(() => { + _setGraphTypeCheck(false); + _invalidateTypeScriptTranspileCache(); + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("_getCompileProvider", () => { + const PROVIDER = "{ compile: function () {}, registerHook: function () {}, transpile: function () { return 'custom'; } }"; + + it("uses the built in TypeScript provider when none is named", () => { + assert.equal(_getCompileProvider(), getCompileProvider()); + assert.equal(_getCompileProvider(""), getCompileProvider()); + }); + + it("loads a named provider module, a provider factory, or a default export", () => { + const direct = _getCompileProvider(write("direct.js", "module.exports = " + PROVIDER + ";")); + const factory = _getCompileProvider(write("factory.js", "module.exports = { getCompileProvider: function () { return " + PROVIDER + "; } };")); + const wrapped = _getCompileProvider(write("wrapped.js", "module.exports = { default: " + PROVIDER + " };")); + + [ direct, factory, wrapped ].forEach((loaded) => { + assert.equal(loaded.transpile("a.ts", "", scratch), "custom"); + }); + }); + + it("rejects a provider without compile or registerHook as a user error", () => { + const noCompile = write("noCompile.js", "module.exports = { registerHook: function () {} };"); + const noHook = write("noHook.js", "module.exports = { compile: function () {} };"); + + [ [ noCompile, "missing compile(options)" ], [ noHook, "missing registerHook(options)" ] ].forEach((entry) => { + let failure: any = null; + try { + _getCompileProvider(entry[0]); + } catch (e) { + failure = e; + } + + assert.isTrue(_isUserError(failure), entry[0]); + assert.includes(failure.message, "Invalid compile provider '" + entry[0] + "': " + entry[1]); + }); + }); + + it("reports a provider that cannot be loaded as a user error", () => { + let failure: any = null; + try { + _getCompileProvider(path.join(scratch, "missing.js")); + } catch (e) { + failure = e; + } + + assert.isTrue(_isUserError(failure)); + }); + + it("passes through a module that exports nothing", () => { + assert.isUndefined(_getCompileProvider(write("empty.js", "module.exports = null;"))); + }); + }); + + describe("compiler wrappers", () => { + it("registers the hook through the provider", () => { + const provider = getCompileProvider(); + const orgRegister = provider.registerHook; + const calls: any[] = []; + provider.registerHook = function () { + calls.push(Array.prototype.slice.call(arguments)); + }; + + try { + _registerTypeScriptHook("tsconfig.json", scratch, true, { strict: true }, { incremental: true }); + } finally { + provider.registerHook = orgRegister; + } + + assert.deepEqual(calls, [ [ "tsconfig.json", scratch, true, { strict: true }, { incremental: true } ] ]); + }); + + it("reports the compile cache status", () => { + assert.equal(_getCompileCacheStatus(), getCompileCacheStatus()); + }); + + it("lists the project's files, or null when the project cannot be read", () => { + write("tsconfig.json", JSON.stringify({ compilerOptions: { types: [] }, include: [ "*.ts" ] })); + const main = write("main.ts", "export const a = 1;\n"); + + assert.deepEqual((_getProjectFileNames("tsconfig.json", scratch) || []).map((f) => path.resolve(f)), [ main ]); + assert.isNull(_getProjectFileNames("missing.json", scratch)); + assert.isNull(_getProjectFileNames(null as any, scratch), "a throw is not passed on"); + + write("later.ts", "export const b = 2;\n"); + _invalidateTypeScriptTranspileCache(); + assert.equal((_getProjectFileNames("tsconfig.json", scratch) || []).length, 2, "invalidated with the program cache"); + }); + + it("turns the graph type-check on and off", () => { + write("tsconfig.json", JSON.stringify({ compilerOptions: { types: [], lib: [ "es2018" ] }, include: [ "*.ts" ] })); + const main = write("main.ts", "export const a = 1;\n"); + write("bad.ts", "export const b: number = \"b\";\n"); + + _setGraphTypeCheck(true); + assert.throws(() => transpileTypeScriptSource(main, "", scratch), "TypeScript compile failed with 1 error(s)"); + + _setGraphTypeCheck(false); + _invalidateTypeScriptTranspileCache(); + assert.includes(transpileTypeScriptSource(main, "", scratch), "exports.a = 1"); + }); + }); + + describe("_resolveBrowserDependency", () => { + it("resolves packages from the module's folder or the working directory", () => { + const runnerDir = path.resolve(__dirname, "../../.."); + + assert.includes(_resolveBrowserDependency("typescript", path.join(scratch, "a.ts"), runnerDir).split(path.sep).join("/"), "/typescript/"); + }); + + it("resolves relative imports node can find", () => { + const helper = write("lib/helper.js", "module.exports = 1;"); + + assert.equal(_resolveBrowserDependency("./lib/helper", path.join(scratch, "a.ts"), scratch), helper); + }); + + it("finds relative imports with extensions node does not load", () => { + const component = write("view.jsx", "export default 1;"); + const indexed = write("widgets/index.jsx", "export default 2;"); + const moduleId = path.join(scratch, "a.ts"); + + assert.equal(_resolveBrowserDependency("./view", moduleId, scratch), component); + assert.equal(_resolveBrowserDependency("./widgets", moduleId, scratch), indexed, "a folder resolves to its index file, not itself"); + assert.equal(_resolveBrowserDependency(component, path.join(scratch, "deep", "b.ts"), scratch), component, "an absolute path"); + }); + + it("passes on node's error for an import it cannot find", () => { + const moduleId = path.join(scratch, "a.ts"); + fs.mkdirSync(path.join(scratch, "empty")); + + assert.throws(() => _resolveBrowserDependency("./missing", moduleId, scratch), "Cannot find module"); + assert.throws(() => _resolveBrowserDependency("./empty", moduleId, scratch), "Cannot find module"); + assert.throws(() => _resolveBrowserDependency("no-such-package-anywhere", moduleId, scratch), "Cannot find module"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/graphWorker.test.ts b/internal/runner/test/src/execute/graphWorker.test.ts new file mode 100644 index 0000000..4b538aa --- /dev/null +++ b/internal/runner/test/src/execute/graphWorker.test.ts @@ -0,0 +1,115 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { _startGraphWorker } from "../../../src/execute/graphWorker"; +import { _isUserError, _userErrorHints } from "../../../src/internal/userError"; + +/** + * Building the browser and worker module graph on a worker thread. The thread loads the runner's own + * bundle and calls its graph entry point; here a stand-in bundle answers in each of the ways the real + * one can. + */ +describe("execute graphWorker", function () { + // Each case starts a worker thread + this.timeout(30000); + + let scratch: string; + + // A stand-in for the runner bundle, whose graph entry runs the given body on the worker thread + function bin(body: string): string { + const file = path.join(scratch, "bin-" + Math.random().toString(36).substring(2) + ".js"); + fs.writeFileSync(file, [ + "const { parentPort, workerData } = require(\"worker_threads\");", + "exports._graphWorkerEntry = function () {", + body, + "};" + ].join("\n"), "utf8"); + + return file; + } + + async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e; + } + + throw new Error("expected the graph worker to fail"); + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-graph-worker-")); + }); + + afterEach(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + it("stays on the main thread unless the graph thread is asked for and a bundle is given", () => { + const entry = bin("parentPort.postMessage({ ok: true, graph: {} });"); + + assert.isNull(_startGraphWorker([], scratch, null, false, entry)); + assert.isNull(_startGraphWorker([], scratch, {}, false, entry)); + assert.isNull(_startGraphWorker([], scratch, { graphThread: false }, false, entry)); + assert.isNull(_startGraphWorker([], scratch, { graphThread: true }, false, "")); + }); + + it("stays on the main thread when the run options cannot be sent to a thread", () => { + const entry = bin("parentPort.postMessage({ ok: true, graph: {} });"); + + assert.isNull(_startGraphWorker([], scratch, { graphThread: true, notCloneable: () => 1 }, false, entry)); + }); + + it("resolves with the graph the thread builds from the files and options it was given", async () => { + const entry = bin("parentPort.postMessage({ ok: true, graph: { modules: {}, entryIds: workerData.entryFiles, echo: [ workerData.cwd, workerData.typeCheck, workerData.parsed.target ] } });"); + + const graph: any = await _startGraphWorker([ "a.test.ts", "b.test.ts" ], scratch, { graphThread: true, target: "worker" }, true, entry); + + assert.deepEqual(graph.entryIds, [ "a.test.ts", "b.test.ts" ]); + assert.deepEqual(graph.echo, [ scratch, true, "worker" ]); + }); + + it("rejects with the thread's user error, keeping its stack and hints", async () => { + const entry = bin("parentPort.postMessage({ ok: false, message: \"TypeScript compile failed\", stack: \"Error: TypeScript compile failed\\n at thread\", user: true, hints: [ \"fix it\" ] });"); + + const failure = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, entry)); + + assert.equal(failure.message, "TypeScript compile failed"); + assert.equal(failure.stack, "Error: TypeScript compile failed\n at thread"); + assert.isTrue(_isUserError(failure)); + assert.deepEqual(_userErrorHints(failure), [ "fix it" ]); + }); + + it("rejects with a plain error when the thread's failure has no details", async () => { + const noMessage = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("parentPort.postMessage({ ok: false });"))); + const noGraph = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("parentPort.postMessage({ ok: true });"))); + const nothing = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("parentPort.postMessage(null);"))); + + [ noMessage, noGraph, nothing ].forEach((failure) => { + assert.equal(failure.message, "the module graph build failed"); + assert.isFalse(_isUserError(failure)); + }); + }); + + it("rejects with the error thrown on the thread", async () => { + const failure = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("throw new Error(\"graph exploded\");"))); + + assert.equal(failure.message, "graph exploded"); + }); + + it("rejects when the thread exits without answering", async () => { + const failure = await rejection(_startGraphWorker([], scratch, { graphThread: true }, false, bin("process.exit(3);"))); + + assert.equal(failure.message, "the module graph worker exited with code 3"); + }); +}); diff --git a/internal/runner/test/src/execute/moduleAssets.test.ts b/internal/runner/test/src/execute/moduleAssets.test.ts new file mode 100644 index 0000000..8ee4655 --- /dev/null +++ b/internal/runner/test/src/execute/moduleAssets.test.ts @@ -0,0 +1,152 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as ts from "typescript"; +import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping"; +import { assert } from "@nevware21/tripwire"; +import { _buildBundledModuleAsset, _buildModuleAssetRegistry } from "../../../src/execute/moduleAssets"; + +const DEFINE_ARGS = "function(require, module, exports, global, globalThis, window, self, process, __filename, __dirname) {\n"; + +function compile(fileName: string, source: string, withMap: boolean): string { + return ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2018, inlineSourceMap: withMap, inlineSources: withMap }, + fileName: fileName + }).outputText; +} + +function inlineMap(map: any): string { + return "\n//# sourceMappingURL=data:application/json;base64," + Buffer.from(JSON.stringify(map)).toString("base64"); +} + +function lineOf(text: string, value: string): number { + const lines = text.split("\n"); + for (let lp = 0; lp < lines.length; lp++) { + if (lines[lp].indexOf(value) !== -1) { + return lp + 1; + } + } + + return -1; +} + +/** + * Wrapping the compiled test modules for the browser and worker runner: one served asset per module, or + * one bundle holding them all, with each module's source map carried over so DevTools and stack traces + * still point at the original TypeScript. + */ +describe("execute moduleAssets", () => { + const urls: Record = { + "/proj/src/a.ts": "/src/a.js", + "/proj/src/b.ts": "/src/b.js" + }; + + describe("_buildModuleAssetRegistry", () => { + it("wraps each served module in a define call for its url", () => { + const registry = _buildModuleAssetRegistry({ "/proj/src/a.ts": "exports.a = 1;" }, urls); + + assert.deepEqual(Object.keys(registry), [ "/src/a.js" ]); + assert.equal(registry["/src/a.js"], "__tripwireDefine__(\"/src/a.js\", " + DEFINE_ARGS + "exports.a = 1;\n});\n"); + }); + + it("skips modules that have no url", () => { + assert.deepEqual(_buildModuleAssetRegistry({ "/proj/src/unserved.ts": "exports.x = 1;" }, urls), {}); + }); + + it("points each require of a served module at its url, leaving the rest alone", () => { + const registry = _buildModuleAssetRegistry({ + "/proj/src/a.ts": "const b = require(\"/proj/src/b.ts\");\nconst fs = require(\"fs\");\nconst odd = require(\"bad\\q\");\n" + }, urls); + + const body = registry["/src/a.js"]; + assert.includes(body, "require(\"/src/b.js\")"); + assert.includes(body, "require(\"fs\")"); + assert.includes(body, "require(\"bad\\q\")", "an id that is not valid JSON is kept"); + }); + + it("moves an inline source map to its own asset, shifted for the define line", () => { + const compiled = compile("/proj/src/a.ts", "export const a: number = 1;\n", true); + const registry = _buildModuleAssetRegistry({ "/proj/src/a.ts": compiled }, urls); + + const map = JSON.parse(registry["/src/a.js.map"]); + const original = JSON.parse(Buffer.from(compiled.split("base64,")[1], "base64").toString("utf8")); + assert.equal(map.mappings, ";" + original.mappings); + assert.includes(registry["/src/a.js"], "\n//# sourceMappingURL=a.js.map\n});\n"); + assert.isFalse(registry["/src/a.js"].indexOf("base64,") !== -1, "the inline map is removed"); + }); + + it("keeps a module whose inline map cannot be read as it is", () => { + const source = "exports.a = 1;\n//# sourceMappingURL=data:application/json;base64," + Buffer.from("not json").toString("base64"); + const registry = _buildModuleAssetRegistry({ "/proj/src/a.ts": source }, urls); + + assert.isUndefined(registry["/src/a.js.map"]); + assert.includes(registry["/src/a.js"], source); + }); + }); + + describe("_buildBundledModuleAsset", () => { + it("defines every served module in one bundle, without a map when none had one", () => { + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": "exports.a = require(\"/proj/src/b.ts\");\n//# sourceMappingURL=a.js.map", + "/proj/src/b.ts": "exports.b = 2;\n", + "/proj/src/unserved.ts": "exports.x = 1;" + }, urls, "/__tripwire__/bundle.js"); + + assert.isNull(bundle.map); + assert.equal(bundle.code, + "__tripwireDefine__(\"/src/a.js\", " + DEFINE_ARGS + "exports.a = require(\"/src/b.js\");\n});\n" + + "__tripwireDefine__(\"/src/b.js\", " + DEFINE_ARGS + "exports.b = 2;\n});\n"); + }); + + it("merges the modules' maps into one, mapping each module back to its own source", () => { + const aSource = "export const a: number = 1;\nexport function first(): number {\n return a;\n}\n"; + const bSource = "export const b: string = \"two\";\n\nexport function second(): string {\n const label: string = \"second\";\n return label;\n}\n"; + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": compile("/proj/src/a.ts", aSource, true), + "/proj/src/b.ts": compile("/proj/src/b.ts", bSource, true) + }, urls, "/__tripwire__/bundle.js"); + + assert.isString(bundle.map); + const mapComment = "//# sourceMappingURL=bundle.js.map\n"; + assert.equal(bundle.code.lastIndexOf(mapComment), bundle.code.length - mapComment.length, "the map is linked at the end"); + assert.isFalse(bundle.code.indexOf("base64,") !== -1, "the inline maps are removed"); + + const map = JSON.parse(bundle.map); + assert.equal(map.file, "bundle.js"); + assert.deepEqual(map.sources, [ "/src/a.ts", "/src/b.ts" ]); + assert.deepEqual(map.sourcesContent, [ aSource, bSource ]); + + const trace = new TraceMap(bundle.map); + const secondLine = lineOf(bundle.code, "return label;"); + const position = originalPositionFor(trace, { line: secondLine, column: bundle.code.split("\n")[secondLine - 1].indexOf("return") }); + assert.equal(position.source, "/src/b.ts"); + assert.equal(position.line, 5); + }); + + it("ignores mappings without a source and maps it cannot read", () => { + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": "exports.a = 1;" + inlineMap({ version: 3, sources: [], names: [], mappings: "A" }), + "/proj/src/b.ts": "exports.b = 2;" + inlineMap({ version: 3, sources: [ "b.ts" ], names: [], mappings: 42 }) + }, urls, "/bundle.js"); + + assert.isNull(bundle.map); + assert.includes(bundle.code, "exports.a = 1;\n});\n"); + assert.includes(bundle.code, "exports.b = 2;\n});\n"); + }); + + it("copies no source content when a module's map has none", () => { + const bundle = _buildBundledModuleAsset({ + "/proj/src/a.ts": compile("/proj/src/a.ts", "export const a = 1;\n", false) + inlineMap({ version: 3, sources: [ "a.ts" ], names: [], mappings: "AAAA" }) + }, urls, "/bundle.js"); + + const map = JSON.parse(bundle.map); + assert.deepEqual(map.sources, [ "/src/a.ts" ]); + assert.isFalse(!!(map.sourcesContent && map.sourcesContent[0]), "no content"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/moduleGraph.test.ts b/internal/runner/test/src/execute/moduleGraph.test.ts new file mode 100644 index 0000000..20e83ee --- /dev/null +++ b/internal/runner/test/src/execute/moduleGraph.test.ts @@ -0,0 +1,269 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { _createBrowserWorkerModuleGraph } from "../../../src/execute/execute"; +import { _isUserError } from "../../../src/internal/userError"; + +const TS_CONFIG = { + compilerOptions: { target: "es2018", module: "commonjs", esModuleInterop: true, skipLibCheck: true, types: [] as string[], lib: [ "es2018" ] }, + include: [ "src/**/*.ts", "test/**/*.ts" ] +}; + +function id(file: string): string { + return path.resolve(file).split(path.sep).join("/"); +} + +function decodeInlineMap(source: string): any { + const parts = source.split("sourceMappingURL=data:application/json;base64,"); + + return parts.length === 2 ? JSON.parse(Buffer.from(parts[1].trim(), "base64").toString("utf8")) : null; +} + +/** + * Building the module graph the browser and worker targets run: every module reachable from the test + * entries, compiled, with each require pointed at the module's id, and the url each one is served at. + * + * The fixture project is named @nevware21/tripwire, the way core's own suite is, so its own small + * src/index.ts stands in for the tripwire runtime the graph always includes. + */ +describe("execute module graph", function () { + // Each graph compiles the fixture through a TypeScript program + this.timeout(60000); + + let scratch: string; + let project: string; + let logs: string[]; + let orgLog: typeof console.log; + + function write(dir: string, name: string, text: string): string { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + function graphFor(entries: string[], parsed?: any) { + return _createBrowserWorkerModuleGraph(entries.map((name) => path.join(project, name)), project, parsed || {}); + } + + function failureFor(entries: string[]): any { + try { + graphFor(entries); + } catch (e) { + return e; + } + + return null; + } + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-module-graph-")); + project = path.join(scratch, "project"); + + write(project, "package.json", JSON.stringify({ name: "@nevware21/tripwire", version: "1.0.0" })); + write(project, "tsconfig.json", JSON.stringify(TS_CONFIG)); + write(project, "src/index.ts", "import { EOL } from \"os\";\nexport const eol: string = EOL;\nexport function describe(name: string, fn: () => void): void {\n fn();\n}\n"); + write(project, "src/helper.ts", "import { red } from \"@nevware21/chromacon\";\nexport function warn(text: string): string {\n return red(text);\n}\n"); + write(project, "src/unused.ts", "export const unused = 1;\n"); + write(project, "src/data.json", "{ \"answer\": 42 }"); + write(project, "test/a.test.ts", [ + "import { describe } from \"@nevware21/tripwire\";", + "import { warn } from \"../src/helper\";", + "declare const require: any;", + "const data = require(\"../src/data.json\");", + "const vendor = require(\"vendor-pkg\");", + "const thing = require(\"modpkg\");", + "describe(\"a\", () => warn(String(data.answer) + vendor + thing.thing));", + "" + ].join("\n")); + + // A vendor package whose source map stays a separate file, served from disk + write(project, "node_modules/vendor-pkg/package.json", JSON.stringify({ name: "vendor-pkg", version: "2.0.0", main: "index.js" })); + write(project, "node_modules/vendor-pkg/index.js", "module.exports = require(\"./lib\");\n"); + write(project, "node_modules/vendor-pkg/lib.js", "module.exports = \"vendor\";\n//# sourceMappingURL=lib.js.map"); + write(project, "node_modules/vendor-pkg/lib.js.map", JSON.stringify({ version: 3, sources: [ "lib.ts" ], names: [], mappings: "AAAA" })); + + // A package shipping its build/es5/mod output, whose map is pointed back at its TypeScript sources + write(project, "node_modules/modpkg/package.json", JSON.stringify({ name: "modpkg", version: "1.0.0", main: "build/es5/mod/thing.js" })); + write(project, "node_modules/modpkg/build/es5/mod/thing.js", "exports.thing = 1;\n//# sourceMappingURL=thing.js.map"); + write(project, "node_modules/modpkg/build/es5/mod/thing.js.map", JSON.stringify({ version: 3, sources: [ "thing.js" ], names: [], mappings: "AAAA" })); + write(project, "node_modules/modpkg/src/thing.ts", "export const thing = 1;\n"); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + beforeEach(() => { + logs = []; + orgLog = console.log; + console.log = (...args: any[]) => { + logs.push(replaceAnsi(args.join(" "), "")); + }; + }); + + afterEach(() => { + console.log = orgLog; + }); + + describe("a project's modules", () => { + let graph: ReturnType; + + before(() => { + const orgWrite = console.log; + console.log = () => {}; + try { + graph = graphFor([ "test/a.test.ts" ]); + } finally { + console.log = orgWrite; + } + }); + + it("starts from the test entries and the tripwire runtime", () => { + assert.deepEqual(graph.entryIds, [ id(path.join(project, "test/a.test.ts")) ]); + assert.equal(graph.tripwireModuleId, id(path.join(project, "src/index.ts"))); + assert.deepEqual(graph.coverageOnlyIds, []); + }); + + it("compiles each TypeScript module and points its requires at the module ids", () => { + const entry = graph.modules[id(path.join(project, "test/a.test.ts"))]; + + assert.includes(entry, "require(\"" + graph.tripwireModuleId + "\")"); + assert.includes(entry, "require(\"" + id(path.join(project, "src/helper.ts")) + "\")"); + assert.includes(entry, "require(\"" + id(path.join(project, "src/data.json")) + "\")"); + assert.includes(entry, "require(\"" + id(path.join(project, "node_modules/vendor-pkg/index.js")) + "\")"); + assert.isFalse(entry.indexOf("sourceMappingURL=data:") !== -1, "no source map unless asked for"); + }); + + it("gives the os module a browser shim and chromacon its browser build", () => { + const runtime = graph.modules[graph.tripwireModuleId]; + const helper = graph.modules[id(path.join(project, "src/helper.ts"))]; + const chromaconId = Object.keys(graph.modules).filter((moduleId) => /\/chromacon\.js$/.test(moduleId))[0]; + + assert.includes(runtime, "require(\"/__tripwire__/browser/os-shim.js\")"); + assert.includes(graph.modules["/__tripwire__/browser/os-shim.js"], "EOL: '\\n'"); + assert.isString(chromaconId, "the browser build of chromacon is in the graph"); + assert.includes(helper, "require(\"" + chromaconId + "\")"); + assert.equal(graph.moduleUrls[chromaconId].indexOf("/node_modules/@nevware21/chromacon/"), 0); + }); + + it("wraps a JSON module as a CommonJS export", () => { + assert.equal(graph.modules[id(path.join(project, "src/data.json"))], "module.exports = { \"answer\": 42 };"); + }); + + it("serves each module at a url mirroring its path, as JavaScript", () => { + assert.equal(graph.moduleUrls[id(path.join(project, "test/a.test.ts"))], "/test/a.test.js"); + assert.equal(graph.moduleUrls[graph.tripwireModuleId], "/src/index.js"); + assert.equal(graph.moduleUrls[id(path.join(project, "src/data.json"))], "/src/data.json"); + assert.equal(graph.moduleUrls[id(path.join(project, "node_modules/vendor-pkg/lib.js"))], "/node_modules/vendor-pkg/lib.js"); + assert.equal(graph.moduleUrls["/__tripwire__/browser/os-shim.js"], "/__tripwire__/browser/os-shim.js"); + }); + + it("serves a vendor module's separate source map from disk", () => { + assert.equal(graph.rawAssets["/node_modules/vendor-pkg/lib.js.map"], id(path.join(project, "node_modules/vendor-pkg/lib.js.map"))); + assert.includes(graph.modules[id(path.join(project, "node_modules/vendor-pkg/lib.js"))], "//# sourceMappingURL=lib.js.map"); + }); + + it("points a build/es5/mod source map back at the package's TypeScript sources", () => { + const thing = graph.modules[id(path.join(project, "node_modules/modpkg/build/es5/mod/thing.js"))]; + const map = decodeInlineMap(thing); + + assert.isObject(map, "the map is inlined"); + assert.deepEqual(map.sources, [ "/node_modules/modpkg/src/thing.ts" ]); + assert.deepEqual(map.sourcesContent, [ "export const thing = 1;\n" ]); + assert.equal(map.sourceRoot, ""); + }); + + it("reports the size of the graph it built", () => { + assert.isTrue(graph.elapsedMs >= 0); + }); + }); + + describe("options", () => { + it("adds the project's other sources for coverage, with source maps", () => { + const graph = graphFor([ "test/a.test.ts" ], { coverage: true, coverageSrc: [ "src/**/*.ts" ] }); + + assert.isTrue(graph.coverageOnlyIds.indexOf(id(path.join(project, "src/unused.ts"))) !== -1, "a source nothing imports is included"); + assert.isObject(decodeInlineMap(graph.modules[id(path.join(project, "src/unused.ts"))])); + assert.includes(logs.join("\n"), "Module graph: "); + }); + + it("adds source maps for an interactive launch, but not for puppeteer", () => { + const entry = id(path.join(project, "test/a.test.ts")); + + assert.isObject(decodeInlineMap(graphFor([ "test/a.test.ts" ], { sourceMaps: true, browserLaunch: "open" }).modules[entry])); + assert.isNull(decodeInlineMap(graphFor([ "test/a.test.ts" ], { sourceMaps: true, browserLaunch: "puppeteer" }).modules[entry])); + }); + }); + + describe("modules from outside the project", () => { + it("serves a package outside the project under its name and version", () => { + write(scratch, "outside/pkg/package.json", JSON.stringify({ name: "outside-pkg", version: "3.0.0" })); + write(scratch, "outside/pkg/lib/util.js", "module.exports = 3;\n"); + write(project, "test/outside.test.ts", "declare const require: any;\nexport const value = require(\"../../outside/pkg/lib/util\");\n"); + + const graph = graphFor([ "test/outside.test.ts" ]); + + assert.equal(graph.moduleUrls[id(path.join(scratch, "outside/pkg/lib/util.js"))], "/__tripwire__/vendor/outside-pkg@3.0.0/lib/util.js"); + }); + + it("numbers a file that belongs to no package", function () { + for (let dir = scratch; ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, "package.json"))) { + // A package above the temp folder would claim the file + this.skip(); + } + + if (path.dirname(dir) === dir) { + break; + } + } + + write(scratch, "loose/loose.js", "module.exports = 4;\n"); + write(project, "test/loose.test.ts", "declare const require: any;\nexport const value = require(\"../../loose/loose\");\n"); + + const graph = graphFor([ "test/loose.test.ts" ]); + + assert.equal(graph.moduleUrls[id(path.join(scratch, "loose/loose.js"))], "/__tripwire__/vendor/_unpackaged/0/loose.js"); + }); + }); + + describe("modules a browser cannot load", () => { + it("rejects a node builtin as a user error naming the module that needs it", () => { + write(project, "test/builtin.test.ts", "import * as fs from \"node:fs\";\nexport const exists = fs.existsSync;\n"); + + const failure = failureFor([ "test/builtin.test.ts" ]); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "Node builtin module 'node:fs' is not supported in browser/worker target (required by " + id(path.join(project, "test/builtin.test.ts")) + ")"); + }); + + it("rejects a native addon as a user error", () => { + write(project, "test/addon.node", "not really native"); + write(project, "test/native.test.ts", "declare const require: any;\nexport const addon = require(\"./addon.node\");\n"); + + const failure = failureFor([ "test/native.test.ts" ]); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "Native module is not supported in browser/worker target: " + id(path.join(project, "test/addon.node"))); + }); + + it("passes on the error for an import that cannot be found", () => { + write(project, "test/missing.test.ts", "declare const require: any;\nexport const gone = require(\"./not-here\");\n"); + + assert.includes(failureFor([ "test/missing.test.ts" ]).message, "Cannot find module './not-here'"); + }); + }); +}); diff --git a/internal/runner/test/src/execute/puppeteer.test.ts b/internal/runner/test/src/execute/puppeteer.test.ts new file mode 100644 index 0000000..2f8b069 --- /dev/null +++ b/internal/runner/test/src/execute/puppeteer.test.ts @@ -0,0 +1,361 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import "../support/textImports"; +import * as fs from "fs"; +import * as http from "http"; +import * as os from "os"; +import * as path from "path"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { IBrowserWorkerModuleGraph } from "../../../src/execute/browser"; +import { _launchPuppeteerBrowser, _runPuppeteerTarget, _warmUpPuppeteerBrowser } from "../../../src/execute/puppeteer"; +import { _isUserError, _userErrorHints } from "../../../src/internal/userError"; + +function post(url: string, body: string): Promise { + const target = new URL(url); + + return new Promise((resolve, reject) => { + const req = http.request({ + host: target.hostname, + port: target.port, + method: "POST", + path: target.pathname, + agent: false, + headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } + }, (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode)); + }); + + req.on("error", reject); + req.end(body); + }); +} + +interface IFakeBrowser { + visited: string[]; + closed: number; + newPages: number; + pages(): Promise; + newPage(): Promise; + close(): Promise; +} + +/** + * A stand-in for a puppeteer browser. Its page sends the given console messages and then, like the + * runner page would, reports `result` to the server it was pointed at. + */ +function fakeBrowser(options: { result?: any, gotoError?: Error, hasPage?: boolean, closeError?: Error, messages?: any[], loadedAfterMs?: number }): IFakeBrowser { + const listeners: Array<(msg: any) => void> = []; + const page = { + on: (event: string, listener: (msg: any) => void) => { + if (event === "console") { + listeners.push(listener); + } + }, + goto: async (url: string) => { + browser.visited.push(url); + (options.messages || []).forEach((msg) => listeners.forEach((listener) => listener(msg))); + if (options.gotoError) { + throw options.gotoError; + } + + await post(new URL(url).origin + "/api/result", JSON.stringify(options.result)); + if (options.loadedAfterMs) { + // The page reported before puppeteer saw it finish loading + await new Promise((resolve) => setTimeout(resolve, options.loadedAfterMs)); + } + } + }; + + const browser: IFakeBrowser = { + visited: [], + closed: 0, + newPages: 0, + pages: async () => options.hasPage === false ? [] : [ page ], + newPage: async () => { + browser.newPages++; + return page; + }, + close: async () => { + browser.closed++; + if (options.closeError) { + throw options.closeError; + } + } + }; + + return browser; +} + +/** + * Running the browser and worker targets headless: the runner's session is started for real on + * loopback, and a stand-in browser plays the part of puppeteer's. + */ +describe("execute puppeteer", function () { + this.timeout(60000); + + let logs: string[]; + let orgLog: typeof console.log; + + const graph: IBrowserWorkerModuleGraph = { + modules: { "/proj/test/a.test.js": "exports.a = 1;\n" }, + entryIds: [ "/proj/test/a.test.js" ], + tripwireModuleId: "/__tripwire__/tripwire.js", + coverageOnlyIds: [], + moduleUrls: { "/proj/test/a.test.js": "/test/a.test.js" }, + rawAssets: {}, + elapsedMs: 0 + }; + const graphs = { browser: graph, worker: graph }; + + beforeEach(() => { + logs = []; + orgLog = console.log; + console.log = (...args: any[]) => { + logs.push(replaceAnsi(args.join(" "), "")); + }; + }); + + afterEach(() => { + console.log = orgLog; + }); + + describe("_runPuppeteerTarget", () => { + it("opens the target's page to finish on its own and resolves with the reported summary", async () => { + const browser = fakeBrowser({ result: { summary: { passed: 3 } } }); + + const result = await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + + assert.deepEqual(result.summary, { passed: 3 }); + assert.isTrue(result.testMs >= 0); + assert.isTrue(/^http:\/\/127\.0\.0\.1:\d+\/__tripwire__\/browser\?autofinish=1$/.test(browser.visited[0]), browser.visited[0]); + assert.equal(browser.closed, 1); + }); + + it("opens a new page for the worker target when the browser has none", async () => { + const browser = fakeBrowser({ result: { summary: { passed: 1 } }, hasPage: false }); + + await _runPuppeteerTarget("worker", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + + assert.equal(browser.newPages, 1); + assert.includes(browser.visited[0], "/__tripwire__/worker?autofinish=1"); + }); + + it("prints the page's console messages", async () => { + const browser = fakeBrowser({ result: { summary: {} }, messages: [ { text: () => "from the page" }, { text: () => "" }, "a bare message" ] }); + + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + + assert.includes(logs.join("\n"), "from the page"); + assert.includes(logs.join("\n"), "a bare message"); + }); + + it("writes the coverage the page reports for the target", async () => { + const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-puppeteer-coverage-"))); + const covered = path.join(project, "src", "a.js"); + const coverage: any = {}; + coverage[covered] = { + path: covered, + statementMap: { 0: { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } } }, + fnMap: {}, + branchMap: {}, + s: { 0: 1 }, + f: {}, + b: {} + }; + const browser = fakeBrowser({ result: { summary: { passed: 1 }, coverage: coverage } }); + const orgCwd = process.cwd(); + const orgWarn = console.warn; + + // The coverage is written under the working directory + process.chdir(project); + console.warn = () => {}; + try { + await _runPuppeteerTarget("worker", graphs, { bindAddress: "127.0.0.1", coverage: true, coverageReporters: [ "json-summary" ] }, Promise.resolve(browser)); + const written = JSON.parse(fs.readFileSync(path.join(project, "coverage", "worker", "coverage-final.json"), "utf8")); + + assert.deepEqual(Object.keys(written), [ covered ]); + } finally { + process.chdir(orgCwd); + console.warn = orgWarn; + fs.rmSync(project, { recursive: true, force: true }); + } + }); + + it("writes no coverage when the page reports none", async () => { + const browser = fakeBrowser({ result: { summary: { passed: 1 } } }); + + const result = await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1", coverage: true }, Promise.resolve(browser)); + + assert.deepEqual(result.summary, { passed: 1 }); + assert.isTrue(result.reportMs >= 0); + }); + + it("closes the browser and passes the failure on when the page cannot be loaded", async () => { + const browser = fakeBrowser({ gotoError: new Error("navigation failed"), closeError: new Error("already closed") }); + + let failure: any = null; + try { + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.message, "navigation failed"); + assert.equal(browser.closed, 1, "a failure to close is not reported over the real one"); + }); + + it("passes on a failed run the page reports", async () => { + const browser = fakeBrowser({ result: { error: "the page crashed" } }); + + let failure: any = null; + try { + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.message, "the page crashed"); + }); + + it("passes on a failure the page reports before it has finished loading", async () => { + const browser = fakeBrowser({ result: { error: "failed while loading" }, loadedAfterMs: 100 }); + + let failure: any = null; + try { + await _runPuppeteerTarget("browser", graphs, { bindAddress: "127.0.0.1" }, Promise.resolve(browser)); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.message, "failed while loading"); + assert.equal(browser.closed, 1); + }); + }); + + describe("_launchPuppeteerBrowser", () => { + let project: string; + + // A stand-in puppeteer package in the project, which is looked in before the runner's own + function fakePackage(name: string, source: string): void { + const dir = path.join(project, "node_modules", name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: name, version: "0.0.0", main: "index.js" }), "utf8"); + fs.writeFileSync(path.join(dir, "index.js"), source, "utf8"); + } + + async function launchFailure(): Promise { + try { + await _launchPuppeteerBrowser(project); + } catch (e) { + return e; + } + + return null; + } + + const LAUNCH = "exports.launch = (options) => Promise.resolve({ launchedWith: options });\n"; + + beforeEach(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-puppeteer-load-")); + }); + + afterEach(() => { + fs.rmSync(project, { recursive: true, force: true }); + }); + + it("launches headless Chrome through the project's puppeteer", async () => { + fakePackage("puppeteer", LAUNCH); + + const browser = await _launchPuppeteerBrowser(project); + + assert.isTrue(browser.launchedWith.headless); + assert.includes(browser.launchedWith.args.join(" "), "--no-sandbox"); + assert.includes(browser.launchedWith.args.join(" "), "--disable-web-security"); + }); + + it("imports a puppeteer that cannot be required", async () => { + // Refuses require() the first time it loads, as an ES module puppeteer does on an older Node + const key = "__tripwireFakePuppeteer" + Math.random().toString(36).substring(2); + fakePackage("puppeteer", [ + "if (!globalThis." + key + ") {", + " globalThis." + key + " = true;", + " const e = new Error(\"require() of ES Module not supported\");", + " e.code = \"ERR_REQUIRE_ESM\";", + " throw e;", + "}", + LAUNCH + ].join("\n")); + + const browser = await _launchPuppeteerBrowser(project); + + assert.isTrue(browser.launchedWith.headless); + }); + + it("reports a puppeteer that can be neither required nor imported as a user error", async () => { + fakePackage("puppeteer", "const e = new Error(\"require() of ES Module not supported\\nmore detail\");\ne.code = \"ERR_REQUIRE_ASYNC_MODULE\";\nthrow e;\n"); + + const failure = await launchFailure(); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "'puppeteer' is installed but could not be loaded on Node.js " + process.version + ": require() of ES Module not supported"); + assert.isFalse(failure.message.indexOf("more detail") !== -1, "only the first line of the cause"); + assert.includes(_userErrorHints(failure)[0], "Check that the installed puppeteer supports this Node.js version."); + }); + + it("reports the first install that fails to load, after trying puppeteer-core", async () => { + fakePackage("puppeteer", "throw new Error(\"puppeteer is broken\");\n"); + fakePackage("puppeteer-core", "throw new Error(\"puppeteer-core is broken too\");\n"); + + const failure = await launchFailure(); + + assert.isTrue(_isUserError(failure)); + assert.includes(failure.message, "'puppeteer' is installed but could not be loaded"); + assert.includes(failure.message, "puppeteer is broken"); + }); + + it("falls back to puppeteer-core when puppeteer cannot be loaded", async () => { + fakePackage("puppeteer", "throw new Error(\"puppeteer is broken\");\n"); + fakePackage("puppeteer-core", LAUNCH); + + const browser = await _launchPuppeteerBrowser(project); + + assert.isTrue(browser.launchedWith.headless); + }); + }); + + describe("_warmUpPuppeteerBrowser", () => { + it("loads a page in the browser's first tab, or a new one", async () => { + const visited: string[] = []; + const page = { goto: async (url: string) => { + visited.push(url); + } }; + let created = 0; + + await _warmUpPuppeteerBrowser({ pages: async () => [ page ], newPage: async () => page }); + await _warmUpPuppeteerBrowser({ pages: async (): Promise => [], newPage: async () => { + created++; + return page; + } }); + + assert.deepEqual(visited, [ "http://127.0.0.1:1/", "http://127.0.0.1:1/" ]); + assert.equal(created, 1); + }); + + it("ignores a failure, which only means the warm-up did not help", async () => { + await _warmUpPuppeteerBrowser({ pages: async () => [ { goto: async () => { + throw new Error("connection refused"); + } } ] }); + await _warmUpPuppeteerBrowser({ pages: async () => { + throw new Error("browser closed"); + } }); + }); + }); +}); diff --git a/internal/runner/test/src/execute/targetSessionCache.test.ts b/internal/runner/test/src/execute/targetSessionCache.test.ts new file mode 100644 index 0000000..afcd431 --- /dev/null +++ b/internal/runner/test/src/execute/targetSessionCache.test.ts @@ -0,0 +1,319 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { spawnSync } from "child_process"; +import { assert } from "@nevware21/tripwire"; +import { _createTargetSessionCache, _watchPath, InteractiveTarget, watchFiles } from "../../../src/execute/targetSessionCache"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(check: () => boolean, timeoutMs: number = 5000): Promise { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + if (check()) { + return true; + } + + await delay(25); + } + + return check(); +} + +/** + * The interactive browser and worker runner keeps one built module graph per target, rebuilding it only + * when the entry files or a module file changes, and optionally watching those files. + */ +describe("execute targetSessionCache", function () { + // The watch cases wait on real file system notifications + this.timeout(30000); + + let scratch: string; + let disposers: Array<() => void> = []; + + function write(name: string, text: string): string { + const file = path.join(scratch, name); + fs.writeFileSync(file, text, "utf8"); + + return file; + } + + // Moves a file's modified time, so a rebuild check sees it changed without waiting for the clock + function touch(file: string, secondsAhead: number): void { + const when = new Date(Date.now() + secondsAhead * 1000); + fs.utimesSync(file, when, when); + } + + beforeEach(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-sessions-")); + }); + + afterEach(() => { + disposers.forEach((dispose) => dispose()); + disposers = []; + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + describe("watchFiles", () => { + it("reports changes to the watched files once they settle", async () => { + const watched = write("a.ts", "a"); + let changes = 0; + const watcher = watchFiles([ watched ], () => changes++); + disposers.push(() => watcher.close()); + + fs.writeFileSync(watched, "a1", "utf8"); + fs.writeFileSync(watched, "a2", "utf8"); + + assert.isTrue(await waitFor(() => changes > 0), "the change was reported"); + await delay(400); + assert.equal(changes, 1, "a burst of writes is one change"); + }); + + it("ignores other files in the same folder", async () => { + const watched = write("a.ts", "a"); + const other = write("other.ts", "other"); + let changes = 0; + const watcher = watchFiles([ watched ], () => changes++); + disposers.push(() => watcher.close()); + + fs.writeFileSync(other, "changed", "utf8"); + + await delay(500); + assert.equal(changes, 0); + }); + + it("drops a pending change when it is closed", async () => { + const watched = write("a.ts", "a"); + let changes = 0; + const watcher = watchFiles([ watched ], () => changes++); + + fs.writeFileSync(watched, "a1", "utf8"); + await delay(100); + watcher.close(); + watcher.close(); + + await delay(400); + assert.equal(changes, 0); + }); + + it("gives up quietly when a folder cannot be watched", () => { + const watcher = watchFiles([ path.join(scratch, "missing", "a.ts") ], () => { + throw new Error("unexpected"); + }); + + watcher.close(); + }); + + it("reports changes to a file named with forward slashes, as module graph ids are", async () => { + const watched = write("a.ts", "a"); + let changes = 0; + const watcher = watchFiles([ watched.split(path.sep).join("/") ], () => changes++); + disposers.push(() => watcher.close()); + + fs.writeFileSync(watched, "a1", "utf8"); + + assert.isTrue(await waitFor(() => changes > 0), "the change was reported"); + }); + }); + + describe("_watchPath", () => { + // The 8.3 short name Windows gives a path, or "" where there is none (another OS, or a volume + // with short names turned off) + function shortName(longPath: string): string { + if (process.platform !== "win32") { + return ""; + } + + const result = spawnSync("cmd.exe", [ "/d", "/c", "for %I in (\"" + longPath + "\") do @echo %~sI" ], { encoding: "utf8", windowsVerbatimArguments: true }); + const short = result.status === 0 ? String(result.stdout || "").trim() : ""; + + return short && short.toLowerCase() !== longPath.toLowerCase() ? short : ""; + } + + it("spells a path the way the file system does, with the platform's separators", () => { + const file = write("a.ts", "a"); + + const watched = _watchPath(file.split(path.sep).join("/")); + + assert.equal(watched, fs.realpathSync.native(file)); + assert.isTrue(path.isAbsolute(watched)); + if (process.platform === "win32") { + assert.equal(watched.indexOf("/"), -1, "no forward slashes on Windows"); + } + }); + + it("expands an 8.3 short name", function () { + const longDir = path.join(scratch, "a folder with a long name"); + fs.mkdirSync(longDir); + fs.writeFileSync(path.join(longDir, "file.ts"), "f", "utf8"); + const short = shortName(longDir); + if (!short) { + // Nothing to expand on this platform or volume + this.skip(); + } + + assert.equal(_watchPath(path.join(short, "file.ts")), fs.realpathSync.native(path.join(longDir, "file.ts"))); + }); + + it("keeps the name of a file that no longer exists, in its folder's own spelling", () => { + const gone = scratch.split(path.sep).join("/") + "/gone.ts"; + + assert.equal(_watchPath(gone), path.join(fs.realpathSync.native(scratch), "gone.ts")); + }); + + it("keeps a path whose folder does not exist either, made absolute", () => { + const nowhere = path.join(scratch, "missing", "a.ts"); + + assert.equal(_watchPath(nowhere.split(path.sep).join("/")), path.resolve(nowhere)); + }); + }); + + describe("_createTargetSessionCache", () => { + interface ISession { + target: InteractiveTarget; + build: number; + } + + function createCache(entryFiles: { [target: string]: string[] }, moduleFiles: () => string[], watch?: boolean) { + const counts = { builds: 0, resolves: 0 }; + const cache = _createTargetSessionCache({ + resolveEntryFiles: (target) => { + counts.resolves++; + return entryFiles[target].slice(0); + }, + buildGraph: (target, files) => { + const modules: Record = { "/__tripwire__/virtual.js": "virtual" }; + files.concat(moduleFiles()).forEach((file) => { + modules[file] = "source of " + file; + }); + + return { modules, entryIds: files, tripwireModuleId: "", coverageOnlyIds: [], moduleUrls: {}, rawAssets: {}, elapsedMs: 0 }; + }, + assemble: (target) => { + counts.builds++; + return { target, build: counts.builds }; + } + }, { watch: !!watch }); + disposers.push(() => cache.dispose()); + + return { cache, counts }; + } + + it("builds a target once and reuses it while nothing changed", () => { + const entry = write("a.test.ts", "a"); + const { cache, counts } = createCache({ browser: [ entry ], worker: [] }, () => []); + + const first = cache.get("browser"); + assert.deepEqual(first, { target: "browser", build: 1 }); + assert.equal(cache.get("browser"), first); + assert.equal(counts.builds, 1); + assert.equal(cache.getGeneration("browser"), 1); + }); + + it("rebuilds when the entry files change, in any order", () => { + const a = write("a.test.ts", "a"); + const b = write("b.test.ts", "b"); + const entries = { browser: [ a, b ], worker: [] as string[] }; + const { cache, counts } = createCache(entries, () => []); + + cache.get("browser"); + entries.browser = [ b, a ]; + cache.get("browser"); + assert.equal(counts.builds, 1, "the same files in another order"); + + entries.browser = [ a ]; + assert.equal(cache.get("browser").build, 2); + }); + + it("rebuilds when a module file is modified or removed", () => { + const entry = write("a.test.ts", "a"); + const helper = write("helper.ts", "h"); + const { cache } = createCache({ browser: [ entry ], worker: [] }, () => [ helper ]); + + cache.get("browser"); + touch(helper, 10); + assert.equal(cache.get("browser").build, 2, "modified"); + + fs.unlinkSync(helper); + assert.equal(cache.get("browser").build, 3, "removed"); + }); + + it("lists the real module files to watch, without virtual or missing modules", () => { + const entry = write("a.test.ts", "a"); + const missing = path.join(scratch, "missing.ts"); + const { cache } = createCache({ browser: [ entry ], worker: [] }, () => [ missing ]); + + assert.deepEqual(cache.getWatchFiles("browser"), []); + cache.get("browser"); + assert.deepEqual(cache.getWatchFiles("browser"), [ entry ]); + }); + + it("peeks at the cached session without checking it, building only when there is none", () => { + const entry = write("a.test.ts", "a"); + const { cache, counts } = createCache({ browser: [ entry ], worker: [ entry ] }, () => []); + + assert.isUndefined(cache.tryPeek("worker")); + const built = cache.peek("worker"); + assert.equal(built.target, "worker"); + assert.equal(cache.tryPeek("worker"), built); + + touch(entry, 10); + const resolves = counts.resolves; + assert.equal(cache.peek("worker"), built, "a stale session is still returned"); + assert.equal(counts.resolves, resolves, "and nothing was checked"); + }); + + it("numbers builds across targets", () => { + const entry = write("a.test.ts", "a"); + const { cache } = createCache({ browser: [ entry ], worker: [ entry ] }, () => []); + + assert.equal(cache.getGeneration("worker"), 0); + cache.get("browser"); + cache.get("worker"); + assert.equal(cache.getGeneration("browser"), 1); + assert.equal(cache.getGeneration("worker"), 2); + }); + + it("rebuilds in the background when a watched file changes, until disposed", async () => { + const entry = write("a.test.ts", "a"); + const { cache } = createCache({ browser: [ entry ], worker: [] }, () => [], true); + + cache.get("browser"); + fs.writeFileSync(entry, "a changed", "utf8"); + touch(entry, 10); + assert.isTrue(await waitFor(() => cache.getGeneration("browser") === 2), "rebuilt by the watcher"); + + cache.dispose(); + fs.writeFileSync(entry, "a changed again", "utf8"); + touch(entry, 20); + await delay(500); + assert.equal(cache.getGeneration("browser"), 2, "the watcher was closed"); + }); + + it("does not rebuild when a watched file's change leaves it as it was built", async () => { + const pinned = new Date(2020, 0, 1); + const entry = write("a.test.ts", "a"); + fs.utimesSync(entry, pinned, pinned); + const { cache, counts } = createCache({ browser: [ entry ], worker: [] }, () => [], true); + + cache.get("browser"); + const resolves = counts.resolves; + fs.writeFileSync(entry, "a", "utf8"); + fs.utimesSync(entry, pinned, pinned); + + assert.isTrue(await waitFor(() => counts.resolves > resolves), "the watcher checked the session"); + await delay(300); + assert.equal(cache.getGeneration("browser"), 1, "and kept it"); + }); + }); +}); diff --git a/internal/runner/test/src/reporters/addReporters.test.ts b/internal/runner/test/src/reporters/addReporters.test.ts new file mode 100644 index 0000000..d032d2c --- /dev/null +++ b/internal/runner/test/src/reporters/addReporters.test.ts @@ -0,0 +1,61 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert, createNotifyMgr } from "@nevware21/tripwire"; +import { addCargoReporter } from "../../../src/reporters/cargoReporter"; +import { addConsoleReporter } from "../../../src/reporters/consoleReporter"; +import { addDocReporter } from "../../../src/reporters/docReporter"; +import { addDotReporter } from "../../../src/reporters/dotReporter"; +import { addJsonReporter } from "../../../src/reporters/jsonReporter"; +import { addJsonStreamReporter } from "../../../src/reporters/jsonStreamReporter"; +import { addLandingReporter } from "../../../src/reporters/landingReporter"; +import { addListReporter } from "../../../src/reporters/listReporter"; +import { addMarkdownReporter } from "../../../src/reporters/markdownReporter"; +import { addMinReporter } from "../../../src/reporters/minReporter"; +import { addNyanReporter } from "../../../src/reporters/nyanReporter"; +import { addProgressReporter } from "../../../src/reporters/progressReporter"; +import { addTapReporter } from "../../../src/reporters/tapReporter"; +import { addXunitReporter } from "../../../src/reporters/xunitReporter"; +import { makeSink } from "../support/reporterHarness"; + +/** + * Each reporter also has an `add...Reporter` helper, which creates it and registers it with a + * notification manager in one step. Each is checked here against a real manager: registering adds a + * listener, and the handle it returns takes it away again. + */ +describe("reporter registration", () => { + const adders: { [name: string]: (mgr: any, options?: any) => { rm(): void } } = { + cargo: addCargoReporter, + console: addConsoleReporter, + doc: addDocReporter, + dot: addDotReporter, + json: addJsonReporter, + "json-stream": addJsonStreamReporter, + landing: addLandingReporter, + list: addListReporter, + markdown: addMarkdownReporter, + min: addMinReporter, + nyan: addNyanReporter, + progress: addProgressReporter, + tap: addTapReporter, + xunit: addXunitReporter + }; + + Object.keys(adders).forEach((name) => { + it("registers the " + name + " reporter, and removes it with the handle it returns", () => { + const mgr = createNotifyMgr(); + assert.isFalse(mgr.hasListeners()); + + const handle = adders[name](mgr, { console: makeSink() }); + assert.isTrue(mgr.hasListeners()); + + handle.rm(); + assert.isFalse(mgr.hasListeners()); + }); + }); +}); diff --git a/internal/runner/test/src/rollup/circularImports.test.ts b/internal/runner/test/src/rollup/circularImports.test.ts new file mode 100644 index 0000000..f6177d8 --- /dev/null +++ b/internal/runner/test/src/rollup/circularImports.test.ts @@ -0,0 +1,135 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; + +// rollup is one of the runner's own dev dependencies, and loadConfigFile is how its CLI loads a config, +// so each project's real rollup.config.js is checked exactly as `rollup -c --bundleConfigAsCjs` sees it. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { loadConfigFile } = require("rollup/loadConfigFile"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { rollup } = require("rollup"); + +const REPO = path.resolve(__dirname, "..", "..", "..", "..", ".."); + +const PROJECTS = [ + { name: "core", config: path.join(REPO, "core", "rollup.config.js") }, + { name: "shim/chai", config: path.join(REPO, "shim", "chai", "rollup.config.js") }, + { name: "runner", config: path.join(REPO, "internal", "runner", "rollup.config.js") } +]; + +function circularWarning(ids: string[]): any { + return { + code: "CIRCULAR_DEPENDENCY", + ids: ids, + message: "Circular dependency: " + ids.join(" -> ") + }; +} + +/** + * Writes `a.js` and `b.js` importing each other under `dir`, with `a.js` as the entry. + */ +function writeCycle(dir: string): string { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "a.js"), "import { b } from \"./b.js\";\nexport const a = () => b();\n", "utf8"); + fs.writeFileSync(path.join(dir, "b.js"), "import { a } from \"./a.js\";\nexport const b = () => typeof a;\n", "utf8"); + + return path.join(dir, "a.js"); +} + +/** + * Every rollup config in the repository turns a circular import between this repository's own modules + * into a build failure, while a cycle wholly inside a dependency under node_modules stays a warning. + * The check goes through rollup itself: the configs are loaded the way the rollup CLI loads them, and + * the warnings are delivered through the merged options rollup builds from them. + */ +describe("rollup circular import guard", function () { + // Loading a config bundles it first, and the fixture builds run rollup for real + this.timeout(120000); + + let scratch: string; + + before(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-cycles-")); + }); + + after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); + }); + + PROJECTS.forEach((project) => { + describe(project.name, () => { + let options: any[]; + + before(async () => { + const loaded = await loadConfigFile(project.config, { bundleConfigAsCjs: true }); + options = loaded.options; + }); + + it("fails every bundle the config defines on a cycle between the repository's own modules, naming every module in it", () => { + const ids = [ path.join(REPO, "core", "src", "a.ts"), path.join(REPO, "core", "src", "b.ts"), path.join(REPO, "core", "src", "a.ts") ]; + + assert.isTrue(options.length > 0, "the config defines at least one bundle"); + options.forEach((opts, idx) => { + assert.throws(() => opts.onLog("warn", circularWarning(ids)), "Circular import: " + ids.join(" -> "), "bundle " + idx); + }); + }); + + it("falls back to rollup's message when the warning carries no module ids", () => { + assert.throws(() => options[0].onLog("warn", { code: "CIRCULAR_DEPENDENCY", message: "Circular dependency: x -> y -> x" }), + "Circular import: Circular dependency: x -> y -> x"); + }); + + it("leaves a cycle inside a dependency, or any other warning, as a warning", () => { + const inDependency = [ "/repo/node_modules/dep/a.js", "/repo/node_modules/dep/b.js", "/repo/node_modules/dep/a.js" ]; + const onWindows = [ "C:\\repo\\node_modules\\dep\\a.js", "C:\\repo\\node_modules\\dep\\b.js" ]; + + options.forEach((opts) => { + opts.onLog("warn", circularWarning(inDependency)); + opts.onLog("warn", circularWarning(onWindows)); + opts.onLog("warn", { code: "THIS_IS_UNDEFINED", message: "this is undefined" }); + }); + }); + + it("stops a real build that imports its way round in a circle", async () => { + const entry = writeCycle(path.join(scratch, project.name.replace(/\W/g, "-"), "src")); + const opts = Object.assign({}, options[0], { input: entry, plugins: [], external: [] }); + delete opts.output; + + let failure: any = null; + try { + await rollup(opts); + } catch (e) { + failure = e; + } + + assert.isNotNull(failure, "the build fails"); + assert.includes(failure.message, "Circular import: "); + assert.includes(failure.message, "a.js -> "); + assert.includes(failure.message, "b.js"); + }); + + it("still builds when the cycle is inside a package under node_modules", async () => { + const entry = writeCycle(path.join(scratch, project.name.replace(/\W/g, "-"), "node_modules", "dep")); + const opts = Object.assign({}, options[0], { input: entry, plugins: [], external: [] }); + delete opts.output; + + const bundle = await rollup(opts); + try { + const output = await bundle.generate({ format: "es" }); + assert.includes(output.output[0].code, "typeof a", "the bundle was produced"); + } finally { + await bundle.close(); + } + }); + }); + }); +}); diff --git a/internal/runner/test/src/server/server.test.ts b/internal/runner/test/src/server/server.test.ts new file mode 100644 index 0000000..1277bb8 --- /dev/null +++ b/internal/runner/test/src/server/server.test.ts @@ -0,0 +1,618 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import * as http from "http"; +import * as os from "os"; +import * as path from "path"; +import { replaceAnsi } from "@nevware21/chromacon"; +import { assert } from "@nevware21/tripwire"; +import { _failRequest, _startServer } from "../../../src/server/server"; +import { _readDirEntries } from "../../../src/server/handlers/fs"; +import { IServer } from "../../../src/interfaces/IServer"; + +interface IResponse { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +/** + * Sends one request to the server and collects the whole response. The path is sent exactly as given, + * so encoded traversal attempts reach the server undecoded. + */ +function send(server: IServer, method: string, reqPath: string, agent?: http.Agent): Promise { + const target = new URL(server.url); + + return new Promise((resolve, reject) => { + const req = http.request({ + host: target.hostname, + port: target.port, + method: method, + path: reqPath, + agent: agent || false + }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") })); + res.on("error", reject); + }); + + req.on("error", reject); + req.end(); + }); +} + +function makeContext(overrides?: any): any { + const logged: string[] = []; + + return Object.assign({ + logged: logged, + logMsg: (msg: any) => { + logged.push(replaceAnsi(String(msg), "")); + }, + getApiHandler: (): any => undefined, + getAsset: (): any => undefined, + onComplete: () => {} + }, overrides || {}); +} + +/** + * The runner's HTTP server, started for real on loopback: request routing, every built-in handler, the + * filesystem fallback with its containment check, failure handling and shutdown. + */ +describe("server", function () { + this.timeout(30000); + + let fsRoot: string; + + before(() => { + fsRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tripwire-server-")); + fs.writeFileSync(path.join(fsRoot, "hello.txt"), "hello world", "utf8"); + fs.writeFileSync(path.join(fsRoot, "README.md"), "# readme", "utf8"); + fs.writeFileSync(path.join(fsRoot, "data.bin"), "bin", "utf8"); + fs.mkdirSync(path.join(fsRoot, "sub")); + fs.writeFileSync(path.join(fsRoot, "sub", "nested.json"), "{\"a\":1}", "utf8"); + fs.mkdirSync(path.join(fsRoot, "empty")); + }); + + after(() => { + fs.rmSync(fsRoot, { recursive: true, force: true }); + }); + + async function withServer(context: any, fn: (server: IServer) => Promise): Promise { + const server = await _startServer(context); + try { + await fn(server); + } finally { + await server.stop(); + } + } + + describe("lifecycle", () => { + it("listens on loopback, reports its url and port, and stops", async () => { + const context = makeContext({ fsRoot }); + const server = await _startServer(context); + + assert.isTrue(server.isRunning); + assert.isTrue(server.port > 0); + assert.equal(server.url, "http://127.0.0.1:" + server.port + "/"); + assert.includes(context.logged.join("\n"), "Server URL: " + server.url); + + await server.stop(); + assert.isFalse(server.isRunning); + assert.isUndefined(server.stop(), "stopping again has nothing left to close"); + }); + + it("does not wait for a client that keeps its connection open", async () => { + const agent = new http.Agent({ keepAlive: true }); + const server = await _startServer(makeContext({ fsRoot })); + + try { + const res = await send(server, "GET", "/api/status", agent); + assert.equal(res.status, 200); + + const started = Date.now(); + await server.stop(); + assert.isTrue(Date.now() - started < 4000, "the kept-alive socket is destroyed rather than waited for"); + } finally { + agent.destroy(); + } + }); + + it("rejects when it cannot listen on the requested address", async () => { + const context = makeContext({ fsRoot, bindAddress: "999.999.999.999" }); + + let failure: any = null; + try { + await _startServer(context); + } catch (e) { + failure = e; + } + + assert.isNotNull(failure); + assert.ok(failure instanceof Error); + assert.includes(context.logged.join("\n"), "Error in browser runner server: "); + }); + + it("sends every response uncached and embeddable by the same origin", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/hello.txt"); + + assert.equal(res.headers["cache-control"], "no-store"); + assert.equal(res.headers["cross-origin-resource-policy"], "same-origin"); + }); + }); + }); + + describe("root", () => { + it("lists the served folder with the built-in shortcuts", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/"); + + assert.equal(res.status, 200); + assert.equal(res.headers["content-type"], "text/html; charset=utf-8"); + assert.includes(res.body, "Index of /"); + assert.includes(res.body, "[FILE] hello.txt"); + assert.includes(res.body, "[DIR] sub"); + assert.includes(res.body, "href=\"/__tripwire__/browser\""); + assert.includes(res.body, "href=\"/__tripwire__/worker\""); + assert.includes(res.body, "href=\"/status\">Status"); + assert.isFalse(res.body.indexOf("well-known") !== -1, "hidden actions are not listed"); + assert.isFalse(res.body.indexOf("href=\"/api\"") !== -1, "the api is not a page to visit"); + }); + }); + + it("ignores the query string and uses the context's own listing page when it has one", async () => { + let call: any[] = null; + const context = makeContext({ + fsRoot, + browserStyle: "slate", + getFsListingHtml: (...args: any[]) => { + call = args; + return ""; + } + }); + + await withServer(context, async (server) => { + const res = await send(server, "GET", "/?view=1"); + + assert.equal(res.body, ""); + assert.equal(call[0], "slate"); + assert.equal(call[1], ""); + assert.isTrue(call[2].some((e: any) => e.name === "hello.txt" && !e.isDir)); + assert.includes(call[3], "fs-shortcuts"); + }); + }); + + it("answers anything but a GET of / with 404", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "POST", "/")).status, 404); + }); + }); + + it("reports a folder it cannot read", async () => { + await withServer(makeContext({ fsRoot: path.join(fsRoot, "does-not-exist") }), async (server) => { + const res = await send(server, "GET", "/"); + + assert.equal(res.status, 500); + assert.equal(res.body, "Internal Server Error"); + }); + }); + + it("escapes action descriptions and links in the shortcut list", async () => { + const context = makeContext({ + fsRoot, + getServerActions: () => [ { + name: "tools", + handler: (request: any) => { + request.res.end("tools"); + }, + description: "dangerous & \"quoted\"", + href: "/tools?a=1&b=\"2\"" + } ] + }); + + await withServer(context, async (server) => { + const res = await send(server, "GET", "/"); + + assert.includes(res.body, "<b>dangerous</b> & "quoted""); + assert.includes(res.body, "href=\"/tools?a=1&b="2"\">Tools"); + }); + }); + }); + + describe("status", () => { + it("describes itself with examples when no code is given", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/status"); + + assert.equal(res.status, 200); + assert.includes(res.body, "

Status Endpoint

"); + assert.includes(res.body, "/status/404 — 404: Not Found"); + assert.includes(res.body, "/status/404,500 — 404: Not Found or 500: Internal Server Error"); + }); + }); + + it("answers with the requested status and its reason phrase", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/status/418"); + + assert.equal(res.status, 418); + assert.equal(res.body, http.STATUS_CODES[418]); + assert.equal(res.headers["content-type"], "text/plain; charset=utf-8"); + }); + }); + + it("picks one of several comma separated codes", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const seen = new Set(); + for (let lp = 0; lp < 12; lp++) { + seen.add((await send(server, "GET", "/status/201,202")).status); + } + + seen.forEach((code) => assert.isTrue(code === 201 || code === 202, "only a requested code is used")); + }); + }); + + it("rejects codes that are malformed, out of range or not standard", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/status/abc")).body, "Invalid status code: abc"); + assert.equal((await send(server, "GET", "/status/99")).body, "Invalid status code: 99"); + assert.equal((await send(server, "GET", "/status/404.5")).body, "Invalid status code: 404.5"); + assert.equal((await send(server, "GET", "/status/404,")).body, "Invalid status code: "); + + const unsupported = await send(server, "GET", "/status/599"); + assert.equal(unsupported.status, 400); + assert.equal(unsupported.body, "Unsupported status code: 599"); + }); + }); + + it("only answers GET", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "POST", "/status/200")).status, 404); + }); + }); + }); + + describe("well-known", () => { + it("answers Chrome's DevTools project probe with an empty project", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/.well-known/appspecific/com.chrome.devtools.json"); + + assert.equal(res.status, 200); + assert.equal(res.body, "{}"); + assert.equal(res.headers["content-type"], "application/json; charset=utf-8"); + }); + }); + + it("answers anything else with 404", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/.well-known/other.json")).status, 404); + assert.equal((await send(server, "POST", "/.well-known/appspecific/com.chrome.devtools.json")).status, 404); + }); + }); + }); + + describe("api", () => { + it("reports that it is running", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/api/status"); + + assert.equal(res.status, 200); + assert.deepEqual(JSON.parse(res.body), { status: "running" }); + }); + }); + + it("hands any other api url to the context's handler for it, with the parsed query", async () => { + let seen: any = null; + const context = makeContext({ + fsRoot, + getApiHandler: (apiUrl: string) => apiUrl === "/echo" ? (request: any) => { + seen = { url: apiUrl, query: request.query, method: request.method }; + request.res.end("echoed"); + } : undefined + }); + + await withServer(context, async (server) => { + const res = await send(server, "GET", "/api/echo?name=a%20b&flag&=ignored"); + + assert.equal(res.body, "echoed"); + assert.deepEqual(seen.query, { name: "a b", flag: "" }, "a pair without a key is dropped"); + assert.deepEqual(seen.method, { name: "GET", isGet: true, isPost: false }); + assert.equal((await send(server, "GET", "/api/unknown")).status, 404); + }); + }); + }); + + describe("runtime assets", () => { + const assets: { [url: string]: any } = { + "/runner.js": { contentType: "application/javascript", content: Buffer.from("runner()") }, + "/favicon.ico": { contentType: "image/x-icon", content: Buffer.from("ico") }, + "/runner-client.js.map": { contentType: "application/json", content: Buffer.from("{\"map\":1}") }, + "/src/app.ts": { contentType: "text/x-typescript", content: Buffer.from("export {}") } + }; + + function assetContext(): any { + return makeContext({ fsRoot, getAsset: (url: string) => assets[url] }); + } + + it("serves the runner's own assets under /__tripwire__/", async () => { + await withServer(assetContext(), async (server) => { + const res = await send(server, "GET", "/__tripwire__/runner.js"); + + assert.equal(res.status, 200); + assert.equal(res.headers["content-type"], "application/javascript"); + assert.equal(res.body, "runner()"); + assert.equal((await send(server, "GET", "/__tripwire__/missing.js")).status, 404); + assert.equal((await send(server, "POST", "/__tripwire__/runner.js")).status, 404); + }); + }); + + it("routes the favicon, the client source maps and /src/ asked for at the top level to the runner's assets", async () => { + await withServer(assetContext(), async (server) => { + assert.equal((await send(server, "GET", "/favicon.ico")).body, "ico"); + assert.equal((await send(server, "GET", "/runner-client.js.map")).body, "{\"map\":1}"); + assert.equal((await send(server, "GET", "/__tripwire__/nested/runner-client.js.map")).body, "{\"map\":1}", "a nested path under /__tripwire__/ is reduced to the asset name"); + assert.equal((await send(server, "GET", "/src/app.ts")).body, "export {}"); + }); + }); + + it("does not treat a source map name inside another folder as a runtime asset", async () => { + await withServer(assetContext(), async (server) => { + assert.equal((await send(server, "GET", "/some/page/runner-client.js.map")).status, 404, "served from the folder, where it does not exist"); + }); + }); + + it("passes a status code list through undecoded, so an encoded space is rejected", async () => { + await withServer(assetContext(), async (server) => { + assert.equal((await send(server, "GET", "/status/201,%20202")).body, "Invalid status code: %20202"); + }); + }); + }); + + describe("filesystem fallback", () => { + it("serves a file from the served folder with its content type", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const text = await send(server, "GET", "/hello.txt"); + const json = await send(server, "GET", "/sub/nested.json"); + const unknown = await send(server, "GET", "/data.bin"); + + assert.equal(text.status, 200); + assert.equal(text.body, "hello world"); + assert.equal(text.headers["content-type"], "text/plain; charset=utf-8"); + assert.equal(json.headers["content-type"], "application/json; charset=utf-8"); + assert.equal(unknown.headers["content-type"], "application/octet-stream"); + }); + }); + + it("serves a top-level file whose name is not an action, rather than the root page", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/README.md"); + + assert.equal(res.status, 200); + assert.equal(res.body, "# readme"); + }); + }); + + it("lists a folder, with file sizes", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/sub/"); + + assert.equal(res.status, 200); + assert.includes(res.body, "Index of /sub"); + assert.includes(res.body, "[FILE] nested.json (7 bytes)"); + assert.includes((await send(server, "GET", "/empty/")).body, "Index of /empty"); + }); + }); + + it("reports a listing page that fails to render", async () => { + const context = makeContext({ + fsRoot, + getFsListingHtml: () => { + throw new Error("template broke"); + } + }); + + await withServer(context, async (server) => { + assert.equal((await send(server, "GET", "/sub/")).status, 500); + }); + }); + + it("prefers the context's generated asset, and honours an explicit refusal to serve the raw file", async () => { + const context = makeContext({ + fsRoot, + getAsset: (url: string) => { + if (url === "/hello.txt") { + return { contentType: "text/x-generated", content: Buffer.from("generated") }; + } + + return url === "/sub/nested.json" ? null : undefined; + } + }); + + await withServer(context, async (server) => { + const generated = await send(server, "GET", "/hello.txt"); + assert.equal(generated.body, "generated"); + assert.equal(generated.headers["content-type"], "text/x-generated"); + + assert.equal((await send(server, "GET", "/sub/nested.json")).status, 404, "null means the raw file is never served"); + }); + }); + + it("answers a missing file with 404 and anything but GET with 405", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/no-such-file.txt")).status, 404); + + const post = await send(server, "POST", "/hello.txt"); + assert.equal(post.status, 405); + assert.equal(post.headers.allow, "GET"); + }); + }); + + it("refuses paths that escape the served folder, however they are encoded", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + assert.equal((await send(server, "GET", "/..%2f..%2fsecret.txt")).status, 403); + assert.equal((await send(server, "GET", "/sub/..%5c..%5csecret.txt")).status, 403); + assert.equal((await send(server, "GET", "/hello%00.txt")).status, 403); + }); + }); + + it("rejects a malformed percent encoding", async () => { + await withServer(makeContext({ fsRoot }), async (server) => { + const res = await send(server, "GET", "/sub/%E0%A4%A"); + + assert.equal(res.status, 400); + assert.includes(res.body, "malformed URL encoding"); + }); + }); + }); + + describe("custom actions", () => { + it("adds new actions, replaces built-in ones and ignores incomplete ones", async () => { + const context = makeContext({ + fsRoot, + getServerActions: () => [ + null, + { name: "", handler: null }, + { name: "broken" }, + { name: "status", handler: (request: any) => request.res.end("replaced status") }, + { name: "hello", handler: (request: any) => request.res.end("hello " + request.actionUrl) } + ] + }); + + await withServer(context, async (server) => { + assert.equal((await send(server, "GET", "/status/500")).body, "replaced status"); + assert.equal((await send(server, "GET", "/hello")).body, "hello /", "a bare action segment gets the root url"); + assert.equal((await send(server, "GET", "/HELLO/world")).body, "hello /world", "actions match case-insensitively"); + assert.equal((await send(server, "GET", "/broken/x")).status, 404, "the incomplete action was never registered"); + }); + }); + + it("answers 500 when an action throws or rejects, and logs the failure", async () => { + const context = makeContext({ + fsRoot, + getServerActions: () => [ + { + name: "throws", + handler: () => { + throw new Error("sync failure"); + } + }, + { name: "rejects", handler: () => Promise.reject(new Error("async failure")) } + ] + }); + + await withServer(context, async (server) => { + const thrown = await send(server, "GET", "/throws/x"); + assert.equal(thrown.status, 500); + assert.equal(thrown.body, "sync failure"); + + const rejected = await send(server, "GET", "/rejects/x"); + assert.equal(rejected.status, 500); + assert.equal(rejected.body, "Internal Server Error"); + assert.includes(context.logged.join("\n"), "Request failed: async failure"); + }); + }); + + it("answers 500 when the context's asset lookup rejects", async () => { + const context = makeContext({ fsRoot, getAsset: () => Promise.reject(new Error("registry failed")) }); + + await withServer(context, async (server) => { + assert.equal((await send(server, "GET", "/hello.txt")).status, 500); + assert.includes(context.logged.join("\n"), "Request failed: registry failed"); + }); + }); + }); + + describe("verbose logging", () => { + it("logs each request with its status", async () => { + const context = makeContext({ fsRoot, verbose: true }); + + await withServer(context, async (server) => { + await send(server, "GET", "/hello.txt"); + await send(server, "GET", "/no-such-file.txt"); + await send(server, "GET", "/status/503"); + }); + + const log = context.logged.join("\n"); + assert.includes(log, "[http] GET /hello.txt 200"); + assert.includes(log, "[http] GET /no-such-file.txt 404"); + assert.includes(log, "[http] GET /status/503 503"); + }); + }); + + describe("_failRequest", () => { + function fakeRes(overrides?: any): any { + return Object.assign({ statusCode: 200, headersSent: false, writableEnded: false, ended: null, end(body: any) { + this.ended = body; + } }, overrides || {}); + } + + it("answers 500 and logs the error", () => { + const context = makeContext(); + const res = fakeRes(); + + _failRequest(res, context, new Error("boom")); + + assert.equal(res.statusCode, 500); + assert.equal(res.ended, "Internal Server Error"); + assert.includes(context.logged[0], "Request failed: boom"); + }); + + it("leaves a response that has already started alone", () => { + const context = makeContext(); + const started = fakeRes({ headersSent: true }); + const ended = fakeRes({ writableEnded: true }); + + _failRequest(started, context, "late"); + _failRequest(ended, context, "late"); + + assert.equal(started.statusCode, 200); + assert.equal(ended.statusCode, 200); + assert.deepEqual(context.logged, []); + }); + + it("never throws while failing, and copes without a logger", () => { + const res = fakeRes({ + end: () => { + throw new Error("socket gone"); + } + }); + + _failRequest(res, null as any, "plain string error"); + _failRequest(fakeRes(), {} as any, "no logger"); + }); + }); + + describe("_readDirEntries", () => { + it("describes each entry, and resolves an empty folder to no entries", async () => { + const entries = await _readDirEntries(fsRoot); + const sub = entries.filter((e) => e.name === "sub")[0]; + const hello = entries.filter((e) => e.name === "hello.txt")[0]; + + assert.isTrue(sub.isDir); + assert.isUndefined(sub.size); + assert.isFalse(hello.isDir); + assert.equal(hello.size, 11); + assert.ok(hello.modified instanceof Date); + assert.deepEqual(await _readDirEntries(path.join(fsRoot, "empty")), []); + }); + + it("rejects for a folder that does not exist", async () => { + let failure: any = null; + try { + await _readDirEntries(path.join(fsRoot, "missing")); + } catch (e) { + failure = e; + } + + assert.equal(failure && failure.code, "ENOENT"); + }); + }); +}); diff --git a/internal/runner/test/src/support/cliFromSource.js b/internal/runner/test/src/support/cliFromSource.js new file mode 100644 index 0000000..0ba077c --- /dev/null +++ b/internal/runner/test/src/support/cliFromSource.js @@ -0,0 +1,245 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/* + * Runs the tripwire CLI from the runner's TypeScript sources, rather than from the bundle the runner's + * own tests run under, so a coverage run that spawns it measures src/tripwire.ts and src/execute. + * + * node cliFromSource.js + * + * The plan is a list of { cwd, args, report? } invocations. They run one after another in this one + * process, the way --watch re-runs, so the runner's sources are only compiled once. What each invocation + * logged, and the exit code it left, are written to the results file. + * + * `report` plays the part of the person at the browser for an open or manual launch: once the CLI + * prints its runner URL, the runner page is loaded, which builds its tests, and `report` is posted + * to that server as the page's results. + * + * `env` sets environment variables for the step only. + * `tty` makes stdout look like a terminal for the step, which turns on the progress output. + * `stubSpawn` keeps an open launch from starting the system browser: its spawn calls are recorded + * in the step's results as `spawned` instead. + * + * `watch` drives a --watch run, which never returns: { edits: [ { file, text } ] }. Each time the run + * says it is watching, the next edit is written, relative to `cwd`, and the run goes round again. Once + * it is watching after the last edit, the launcher moves on and leaves that run waiting, so a watch + * step's exit code means nothing. The launcher exits once every step is done. + */ +"use strict"; + +const fs = require("fs"); +const http = require("http"); +const path = require("path"); + +const runnerDir = process.argv[2]; +const plan = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); +const resultsFile = process.argv[4]; +const extensions = require.extensions; + +// The browser UX imports its templates and icon as modules, which the rollup build inlines as strings +function _textModule(ext, read) { + extensions[ext] = function (module, filename) { + module.exports = read(filename); + }; +} + +_textModule(".html", (filename) => fs.readFileSync(filename, "utf8")); +_textModule(".css", (filename) => fs.readFileSync(filename, "utf8")); +_textModule(".ico", (filename) => fs.readFileSync(filename).toString("base64")); + +// A throwaway transpile, just long enough to load the runner's own compile module, which then installs +// the real hook over the runner's test project -- a whole-program compile, as the runner's own tests +// get, so the const enums it imports from core are inlined. +// +// Under nyc, `.ts` already has nyc's instrumenting wrapper. The throwaway hook is set *past* that +// wrapper, so this bootstrap copy is never measured: compiled differently, it would not line up with +// the coverage of the same files compiled by the real hook. nyc's wrapper is then put back before the +// real hook is installed, and the bootstrap copy dropped from the cache, so every runner file that is +// measured is compiled one way. +const ts = require(require.resolve("typescript", { paths: [ runnerDir ] })); +const srcDir = path.join(runnerDir, "src"); +const nycTsHook = Object.getOwnPropertyDescriptor(extensions, ".ts"); + +Object.defineProperty(extensions, ".ts", { + configurable: true, + enumerable: true, + writable: true, + value: function (module, filename) { + const output = ts.transpileModule(fs.readFileSync(filename, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2018, + esModuleInterop: true + }, + fileName: filename + }); + + module._compile(output.outputText, filename); + } +}); + +const bootCompile = require(path.join(srcDir, "compile/tsCompile.ts")); + +if (nycTsHook) { + Object.defineProperty(extensions, ".ts", nycTsHook); +} else { + delete extensions[".ts"]; +} + +Object.keys(require.cache).forEach((key) => { + if (key.indexOf(srcDir) === 0) { + delete require.cache[key]; + } +}); + +bootCompile.registerTypeScriptHook(path.join(runnerDir, "test/tsconfig.test.json"), runnerDir); + +const cliPath = path.join(runnerDir, "src/tripwire.ts"); +const cli = require(cliPath); + +function _format(args) { + return Array.prototype.map.call(args, (arg) => (arg && arg.stack) ? arg.stack : String(arg)).join(" "); +} + +// Loads the runner page at `runnerUrl`, as a browser would, then posts `report` as its run's results +function _postReport(runnerUrl, report) { + const target = new URL(runnerUrl); + const body = JSON.stringify(report); + + function send(method, reqPath, data, done) { + const req = http.request({ + host: target.hostname, + port: target.port, + method: method, + path: reqPath, + agent: false, + headers: data ? { "content-type": "application/json", "content-length": Buffer.byteLength(data) } : {} + }, (res) => { + res.resume(); + res.on("end", done); + }); + + req.on("error", () => { + // The run then never completes, and the test waiting on it times out with the output so far + }); + req.end(data); + } + + send("GET", target.pathname, null, () => send("POST", "/api/result", body, () => {})); +} + +// Forget the modules an invocation loaded from its project, so the next run registers its tests again +function _forgetProject(cwd) { + const prefix = path.resolve(cwd); + Object.keys(require.cache).forEach((key) => { + if (key.indexOf(prefix) === 0 && key.indexOf("node_modules") === -1) { + delete require.cache[key]; + } + }); +} + +(async function () { + const results = []; + const orgConsole = { log: console.log, warn: console.warn, error: console.error }; + + for (let lp = 0; lp < plan.length; lp++) { + const step = plan[lp]; + const out = []; + const err = []; + const edits = (step.watch && step.watch.edits) || []; + let reported = false; + let watching = 0; + let watchDone = null; + const watched = step.watch ? new Promise((resolve) => { + watchDone = resolve; + }) : null; + + console.log = function () { + const text = _format(arguments); + const at = text.indexOf("Runner URL: "); + if (step.report && !reported && at !== -1) { + reported = true; + _postReport(text.substring(at + "Runner URL: ".length).trim(), step.report); + } + + if (watched && text.indexOf("Watching ") !== -1) { + watching++; + if (watching > edits.length) { + watchDone(); + } else { + // The run starts its file watcher straight after saying so -- give it a moment + const edit = edits[watching - 1]; + setTimeout(() => fs.writeFileSync(path.resolve(step.cwd, edit.file), edit.text, "utf8"), 500); + } + } + + out.push(text); + }; + console.warn = function () { + err.push(_format(arguments)); + }; + console.error = function () { + err.push(_format(arguments)); + }; + + process.chdir(step.cwd); + process.argv = [ process.argv[0], cliPath ].concat(step.args); + process.exitCode = undefined; + + const orgEnv = {}; + Object.keys(step.env || {}).forEach((name) => { + orgEnv[name] = process.env[name]; + process.env[name] = step.env[name]; + }); + + const orgIsTTY = process.stdout.isTTY; + if (step.tty) { + process.stdout.isTTY = true; + } + + const childProcess = require("child_process"); + const orgSpawn = childProcess.spawn; + const spawned = []; + if (step.stubSpawn) { + childProcess.spawn = function (command, args) { + spawned.push([ command ].concat(args || [])); + + return { unref: () => {} }; + }; + } + + try { + const running = cli.main(); + await (watched ? Promise.race([ running, watched ]) : running); + } catch (e) { + err.push("launcher: " + _format([ e ])); + } + + console.log = orgConsole.log; + console.warn = orgConsole.warn; + console.error = orgConsole.error; + process.stdout.isTTY = orgIsTTY; + childProcess.spawn = orgSpawn; + Object.keys(orgEnv).forEach((name) => { + if (orgEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = orgEnv[name]; + } + }); + + results.push({ args: step.args, exitCode: process.exitCode === undefined ? 0 : process.exitCode, out: out, err: err, spawned: spawned }); + process.exitCode = undefined; + _forgetProject(step.cwd); + } + + fs.writeFileSync(resultsFile, JSON.stringify(results, null, 2), "utf8"); + + // A watch step's run is still waiting on its files + process.exit(0); +})(); diff --git a/internal/runner/test/src/support/textImports.ts b/internal/runner/test/src/support/textImports.ts new file mode 100644 index 0000000..1ababe2 --- /dev/null +++ b/internal/runner/test/src/support/textImports.ts @@ -0,0 +1,31 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as fs from "fs"; +import { createRequire } from "module"; + +/** + * Lets the runner's browser UX load from source under node, the way its bundle does. The UX imports its + * html and css templates and its icon as modules, which the rollup build inlines as strings (the + * inlineText and inlineBinary plugins in rollup.config.js). Importing this module before anything that + * reaches the UX gives node the same loaders, so a test can import the browser runner, and everything + * that imports it, straight from src. + */ +const _extensions: any = (createRequire(__filename) as any).extensions; + +function _register(ext: string, read: (filename: string) => string): void { + if (!_extensions[ext]) { + _extensions[ext] = (module: any, filename: string) => { + module.exports = read(filename); + }; + } +} + +_register(".html", (filename) => fs.readFileSync(filename, "utf8")); +_register(".css", (filename) => fs.readFileSync(filename, "utf8")); +_register(".ico", (filename) => fs.readFileSync(filename).toString("base64")); diff --git a/internal/runner/test/src/support/uxDom.ts b/internal/runner/test/src/support/uxDom.ts new file mode 100644 index 0000000..cf88301 --- /dev/null +++ b/internal/runner/test/src/support/uxDom.ts @@ -0,0 +1,378 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +/** + * The parts of a browser the interactive page's modules (src/ux/browser/ui) touch, as plain objects, + * so those modules can be tested on node. + */ + +/** A listener added to a stand-in element or document. */ +interface IListener { + fn: (evt: any) => void; + capture: boolean; +} + +/** A small DOM event: its type, plus whatever the test sets on it (`key`, ...). */ +export function stubEvent(type: string, extra?: { [name: string]: any }): any { + const evt: any = { + type: type, + target: null, + defaultPrevented: false, + propagationStopped: false, + preventDefault: () => { + evt.defaultPrevented = true; + }, + stopPropagation: () => { + evt.propagationStopped = true; + } + }; + + return Object.assign(evt, extra || {}); +} + +function _listen(listeners: { [type: string]: IListener[] }, type: string, fn: (evt: any) => void, options?: any): void { + const capture = options === true || !!(options && options.capture); + (listeners[type] = listeners[type] || []).push({ fn: fn, capture: capture }); +} + +function _unlisten(listeners: { [type: string]: IListener[] }, type: string, fn: (evt: any) => void): void { + const found = listeners[type] || []; + for (let lp = found.length - 1; lp >= 0; lp--) { + if (found[lp].fn === fn) { + found.splice(lp, 1); + } + } +} + +function _fire(owner: any, evt: any, capture: boolean): void { + const found = ((owner && owner.listeners && owner.listeners[evt.type]) || []).slice(0); + for (let lp = 0; lp < found.length && !evt.propagationStopped; lp++) { + if (found[lp].capture === capture) { + found[lp].fn(evt); + } + } +} + +/** + * Whether `el` matches one simple selector: a tag name, `.class`, `#id`, or an attribute test such as + * `[role='menuitem']` or `[hidden]`. + */ +function _matches(el: any, selector: string): boolean { + const sel = selector.trim(); + const attr = /^\[([\w-]+)(?:=['"]?([^'"\]]*)['"]?)?\]$/.exec(sel); + if (attr) { + return attr[2] === undefined ? el.hasAttribute(attr[1]) : el.getAttribute(attr[1]) === attr[2]; + } + + if (sel.charAt(0) === ".") { + return el.classList.contains(sel.substring(1)); + } + + if (sel.charAt(0) === "#") { + return el.id === sel.substring(1); + } + + return String(el.tagName || "").toLowerCase() === sel.toLowerCase(); +} + +/** + * A plain object with the element members the ui modules under test touch. `kids` holds its children; + * setting `textContent` clears them, as a real element does, while reading it returns only the text + * set on this element (see {@link textOf} for the text of a whole subtree). + */ +export function stubElement(id: string, tagName?: string): any { + const attrs: { [name: string]: string } = {}; + const classes: string[] = []; + const listeners: { [type: string]: IListener[] } = {}; + let text = ""; + let html = ""; + + function _adopt(child: any): void { + if (child && child.parentNode && child.parentNode !== el && child.parentNode.removeChild) { + child.parentNode.removeChild(child); + } + + if (child) { + child.parentNode = el; + } + } + + const el: any = { + id: id, + tagName: (tagName || "").toUpperCase(), + value: "", + checked: false, + disabled: false, + open: false, + style: {}, + attrs: attrs, + kids: [] as any[], + listeners: listeners, + parentNode: null, + scrollTop: 0, + scrollHeight: 0, + clientHeight: 0, + scrolledIntoView: [] as any[], + setAttribute: (name: string, value: any) => { + attrs[name] = String(value); + }, + getAttribute: (name: string) => (name in attrs ? attrs[name] : null), + hasAttribute: (name: string) => name in attrs, + removeAttribute: (name: string) => { + delete attrs[name]; + }, + classList: { + add: (...names: string[]) => { + names.forEach((cls) => { + if (classes.indexOf(cls) === -1) { + classes.push(cls); + } + }); + }, + remove: (...names: string[]) => { + names.forEach((cls) => { + const idx = classes.indexOf(cls); + if (idx !== -1) { + classes.splice(idx, 1); + } + }); + }, + contains: (cls: string) => classes.indexOf(cls) !== -1, + toggle: (cls: string, force?: boolean) => { + const on = force === undefined ? classes.indexOf(cls) === -1 : !!force; + if (on) { + el.classList.add(cls); + } else { + el.classList.remove(cls); + } + + return on; + } + }, + appendChild: (child: any) => { + const added = child && child.fragment ? child.kids.slice(0) : [ child ]; + added.forEach(_adopt); + el.kids.push.apply(el.kids, added); + + return child; + }, + insertBefore: (child: any, ref: any) => { + _adopt(child); + const idx = el.kids.indexOf(ref); + el.kids.splice(idx === -1 ? el.kids.length : idx, 0, child); + + return child; + }, + removeChild: (child: any) => { + el.kids.splice(el.kids.indexOf(child), 1); + if (child) { + child.parentNode = null; + } + + return child; + }, + remove: () => { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + }, + addEventListener: (type: string, fn: (evt: any) => void, options?: any) => _listen(listeners, type, fn, options), + removeEventListener: (type: string, fn: (evt: any) => void) => _unlisten(listeners, type, fn), + // Capture from the top down to this element, then bubble back up, then the document's listeners + dispatchEvent: (evt: any) => { + const path: any[] = []; + for (let node = el; node; node = node.parentNode) { + path.push(node); + } + + evt.target = evt.target || el; + for (let lp = path.length - 1; lp >= 0 && !evt.propagationStopped; lp--) { + _fire(path[lp], evt, true); + } + + for (let lp = 0; lp < path.length && !evt.propagationStopped; lp++) { + _fire(path[lp], evt, false); + } + + const doc = (globalThis as any).document; + if (doc && doc.listeners && !evt.propagationStopped) { + _fire(doc, evt, false); + } + + return !evt.defaultPrevented; + }, + click: () => el.dispatchEvent(stubEvent("click")), + focus: () => { + const doc = (globalThis as any).document; + if (doc) { + doc.activeElement = el; + } + }, + matches: (selector: string) => _matches(el, selector), + closest: (selector: string) => { + for (let node = el; node && node.matches; node = node.parentNode) { + if (node.matches(selector)) { + return node; + } + } + + return null; + }, + contains: (node: any) => { + for (let current = node; current; current = current.parentNode) { + if (current === el) { + return true; + } + } + + return false; + }, + querySelectorAll: (selector: string) => { + const found: any[] = []; + const visit = (node: any) => { + (node.kids || []).forEach((child: any) => { + if (child && child.matches && child.matches(selector)) { + found.push(child); + } + + if (child) { + visit(child); + } + }); + }; + visit(el); + + return found; + }, + querySelector: (selector: string) => el.querySelectorAll(selector)[0] || null, + scrollIntoView: (options?: any) => { + el.scrolledIntoView.push(options); + } + }; + + Object.defineProperty(el, "textContent", { + get: () => text, + set: (value: string) => { + text = value; + el.kids.length = 0; + } + }); + Object.defineProperty(el, "innerHTML", { + get: () => html, + set: (value: string) => { + html = value; + el.kids.length = 0; + } + }); + Object.defineProperty(el, "className", { + get: () => classes.join(" "), + set: (value: string) => { + classes.length = 0; + String(value || "").split(/\s+/).filter(Boolean).forEach((cls) => classes.push(cls)); + } + }); + Object.defineProperty(el, "children", { get: () => el.kids }); + Object.defineProperty(el, "parentElement", { get: () => el.parentNode }); + Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); + Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); + + return el; +} + +/** The text of an element and everything under it, the way a page would show it. */ +export function textOf(el: any): string { + if (!el) { + return ""; + } + + return (el.textContent || "") + (el.kids || []).map(textOf).join(""); +} + +/** + * A stand-in `document` for code that builds elements while it runs. `getElementById` reads the same + * registry as {@link uxDom} -- creating a missing element when `createMissing` is set, as the page's + * `./dom` needs when it loads -- and document-level listeners are kept so a test can fire them. + */ +export function stubDocument(registry?: { [id: string]: any }, createMissing?: boolean): any { + const ids = registry || {}; + const listeners: { [type: string]: IListener[] } = {}; + const doc: any = { + body: stubElement("body", "body"), + activeElement: null, + listeners: listeners, + createElement: (tag: string) => stubElement("", tag), + createDocumentFragment: () => { + const fragment: any = { fragment: true, kids: [] as any[] }; + fragment.appendChild = (child: any) => { + fragment.kids.push(child); + }; + + return fragment; + }, + getElementById: (id: string) => ids[id] || (createMissing ? (ids[id] = stubElement(id)) : null), + addEventListener: (type: string, fn: (evt: any) => void, options?: any) => _listen(listeners, type, fn, options), + removeEventListener: (type: string, fn: (evt: any) => void) => _unlisten(listeners, type, fn) + }; + + return doc; +} + +/** + * Installs a {@link stubDocument} as the global `document` and returns what puts the previous one + * back. Meant for a beforeEach/afterEach pair: nothing outside the test should see a document. + */ +export function useStubDocument(registry?: { [id: string]: any }): { document: any, restore: () => void } { + const g = globalThis as any; + const had = Object.prototype.hasOwnProperty.call(g, "document"); + const previous = g.document; + const doc = stubDocument(registry); + g.document = doc; + + return { + document: doc, + restore: () => { + if (had) { + g.document = previous; + } else { + delete g.document; + } + } + }; +} + +/** + * `./dom` looks every element it exports up from `document` once, as it loads, and node has no + * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, + * created on demand -- and takes `document` away again straight afterwards so nothing else in the + * process can mistake this for a browser. The objects live in a registry on `globalThis` because + * `./dom` loads once per process, for whichever test file gets there first: every file that needs + * them has to read the same objects back, whoever created them. + */ +export function uxDom(): { [id: string]: any } { + const g = globalThis as any; + const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); + const hadDocument = !!g.document; + + if (!hadDocument) { + g.document = { + body: null, + getElementById: (id: string) => registry[id] || (registry[id] = stubElement(id)) + }; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("../../../src/ux/browser/ui/dom"); + } finally { + if (!hadDocument) { + delete g.document; + } + } + + return registry; +} diff --git a/internal/runner/test/src/ux/assertions.test.ts b/internal/runner/test/src/ux/assertions.test.ts index 8ca84d4..0bb1787 100644 --- a/internal/runner/test/src/ux/assertions.test.ts +++ b/internal/runner/test/src/ux/assertions.test.ts @@ -11,104 +11,18 @@ import { assertionDomQueue, assertionFlushScheduled, clearAllState, logSendQueue, setAssertionFlushScheduled, setCurrentExecutingTestKey, setLastObservedTestKey, testState } from "../../../src/ux/browser/ui/state"; +import { useStubDocument, uxDom } from "../support/uxDom"; /** * How an assertion reported by the running tests finds the test node it belongs to and lands in * the render queue. The tests here stop at that queue: the queued entry is what the rendering * module draws, and everything up to it is plain bookkeeping over the shared state. * - * `_handleAssertionEvent` with `suiteParts`/`testName` is not covered -- it builds the test's - * elements through `ensureTestNode`, which needs a real `document`. + * `_handleAssertionEvent` with `suiteParts`/`testName` builds the test's elements through + * `ensureTestNode`, so that one case runs with the stand-in `document` of ../support/uxDom.ts. */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -_uxDom(); +uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const assertions: typeof import("../../../src/ux/browser/ui/assertions") = require("../../../src/ux/browser/ui/assertions"); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -253,6 +167,22 @@ describe("ux assertions", () => { assert.equal(assertionDomQueue.length, 0); assert.deepEqual(logSendQueue, [{ level: "warn", message: "Assertion received without active test context" }]); }); + + it("should draw the test an addressed assertion names, and attribute it there", () => { + const doc = useStubDocument(); + let queued: any[]; + try { + setCurrentExecutingTestKey("some other :: test"); + assertions._handleAssertionEvent({ suiteParts: [ "outer" ], testName: "named", passed: true, label: "equal" }); + queued = assertionDomQueue.slice(0); + } finally { + doc.restore(); + // The drawn test is not this file's to keep + uiRendering.clearResults(null); + } + + assert.deepEqual(queued, [{ testKey: "outer :: named", passed: true, label: "equal", errorText: "" }]); + }); }); describe("handleAssertion", () => { diff --git a/internal/runner/test/src/ux/client.test.ts b/internal/runner/test/src/ux/client.test.ts new file mode 100644 index 0000000..90fbd1b --- /dev/null +++ b/internal/runner/test/src/ux/client.test.ts @@ -0,0 +1,619 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as path from "path"; +import { assert } from "@nevware21/tripwire"; +import { hashSelectionValue } from "../../../src/ux/browser/ui/urlState"; +import { stubDocument, stubEvent, textOf } from "../support/uxDom"; + +/** + * The interactive runner page itself: it starts the test frame (browser target) or the worker (worker + * target), runs the tests once they are found, draws what they report, and sends the results back to + * the CLI. The page starts as it loads and reads its target from the URL once, so each test loads a + * fresh copy of the page's modules, with its own elements, URL, `fetch`, timers and workers stood in + * for. The copies this suite's other files loaded are put back afterwards. + */ + +const UI_DIR = path.resolve(__dirname, "../../../src/ux/browser/ui"); + +const DISCOVERY = { + suites: [ "frame suite" ], + tests: [ + { suiteTitle: "frame suite", testName: "passes", fullTitle: "frame suite > passes" }, + { suiteTitle: "frame suite", testName: "fails", fullTitle: "frame suite > fails" } + ] +}; + +const LIFECYCLE = [ + { kind: "suiteStart", suiteParts: [ "frame suite" ] }, + { kind: "testStart", suiteParts: [ "frame suite" ], testName: "passes" }, + { kind: "assert", suiteParts: [ "frame suite" ], testName: "passes", passed: true, label: "equal" }, + { kind: "testDone", suiteParts: [ "frame suite" ], testName: "passes", endStatus: "passed", durationMs: 1 }, + { kind: "testStart", suiteParts: [ "frame suite" ], testName: "fails" }, + { kind: "testDone", suiteParts: [ "frame suite" ], testName: "fails", endStatus: "failed", durationMs: 2, endErrorText: "boom" }, + { kind: "suiteDone", suiteParts: [ "frame suite" ], durationMs: 3 }, + null +]; + +interface IPage { + registry: { [id: string]: any }; + document: any; + state: any; + logging: any; + listeners: { [type: string]: (evt: any) => void }; + parentPosts: any[]; + framePosts: any[]; + fetches: Array<{ url: string, init: any }>; + intervals: Array<() => void>; + workers: any[]; + hashWrites: string[]; + location: any; + generation: number | null | "fail" | "error"; + workerThrows: string; + frame(): any; + status(): string; + message(data: any): void; +} + +describe("ux client", function () { + this.timeout(30000); + + const g = globalThis as any; + const GLOBALS = [ "document", "window", "location", "history", "fetch", "requestAnimationFrame", "Worker" ]; + let saved: { [name: string]: any }; + let orgConsole: { [level: string]: any }; + let pages: IPage[]; + + // Lets the page's promises, and the zero-delay timers they wait on, run + async function settle(): Promise { + for (let round = 0; round < 2; round++) { + for (let lp = 0; lp < 10; lp++) { + await new Promise((resolve) => setImmediate(resolve)); + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + interface ILoadOptions { + // The URL hash the page loads with + hash?: string; + // Elements the page does not have + omit?: string[]; + // Sending the result to the CLI fails + resultFails?: boolean; + } + + function loadPage(pathname: string, search?: string, bodyAttrs?: { [name: string]: string }, options?: ILoadOptions): IPage { + const opts = options || {}; + const page: IPage = { + registry: {}, + document: null, + state: null, + logging: null, + listeners: {}, + parentPosts: [], + framePosts: [], + fetches: [], + intervals: [], + workers: [], + hashWrites: [], + location: null, + generation: null, + workerThrows: "", + frame: () => page.registry.testFrameContainer.kids[page.registry.testFrameContainer.kids.length - 1], + status: () => page.registry.status.textContent, + message: (data: any) => page.listeners.message({ data: data }) + }; + + page.location = { href: "http://localhost:4321" + pathname + (search || "") + (opts.hash || ""), pathname: pathname, search: search || "", hash: opts.hash || "" }; + page.document = stubDocument(page.registry, true); + const getElementById = page.document.getElementById; + page.document.getElementById = (id: string) => ((opts.omit || []).indexOf(id) !== -1 ? null : getElementById(id)); + Object.keys(bodyAttrs || {}).forEach((name) => page.document.body.setAttribute(name, bodyAttrs[name])); + + g.document = page.document; + g.location = page.location; + g.history = { replaceState: (_state: any, _title: string, url: string) => page.hashWrites.push(url) }; + g.window = { + addEventListener: (type: string, fn: (evt: any) => void) => { + page.listeners[type] = fn; + }, + parent: { postMessage: (msg: any) => page.parentPosts.push(msg) } + }; + g.requestAnimationFrame = (cb: () => void) => { + Promise.resolve().then(cb); + return 1; + }; + g.fetch = (url: string, init?: any) => { + page.fetches.push({ url: url, init: init }); + if (url.indexOf("/__tripwire__/check-fresh") !== -1) { + if (page.generation === "error") { + return Promise.reject(new Error("offline")); + } + + return Promise.resolve({ ok: page.generation !== "fail", json: () => Promise.resolve({ generation: page.generation }) }); + } + + if (opts.resultFails && url.indexOf("/api/result") !== -1) { + return Promise.reject(new Error("the CLI went away")); + } + + if (url.indexOf("/api/payload-") !== -1) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ tripwireModuleId: "/tripwire", entryIds: [], settings: {} }) }); + } + + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }; + g.Worker = function (this: any, url: string) { + if (page.workerThrows) { + throw new Error(page.workerThrows); + } + + const worker: any = this; + worker.url = url; + worker.posted = []; + worker.terminated = false; + worker.postMessage = (msg: any) => worker.posted.push(msg); + worker.terminate = () => { + worker.terminated = true; + }; + page.workers.push(worker); + }; + + // A fresh copy of the page, against elements of its own; this suite's other files keep theirs + const loaded: { [key: string]: any } = {}; + Object.keys(require.cache).forEach((key) => { + if (key.indexOf(UI_DIR) === 0) { + loaded[key] = require.cache[key]; + delete require.cache[key]; + } + }); + const orgRegistry = g.__tripwireUxDomStub; + const orgSetInterval = g.setInterval; + g.__tripwireUxDomStub = page.registry; + g.setInterval = (fn: () => void) => { + page.intervals.push(fn); + return page.intervals.length; + }; + + try { + require(path.join(UI_DIR, "client.ts")); + page.state = require(path.join(UI_DIR, "state.ts")); + page.logging = require(path.join(UI_DIR, "logging.ts")); + pages.push(page); + } finally { + g.setInterval = orgSetInterval; + g.__tripwireUxDomStub = orgRegistry; + Object.keys(require.cache).forEach((key) => { + if (key.indexOf(UI_DIR) === 0) { + delete require.cache[key]; + } + }); + Object.keys(loaded).forEach((key) => { + require.cache[key] = loaded[key]; + }); + + // The page copies the console into its log panel, for good + Object.keys(orgConsole).forEach((level) => { + (console as any)[level] = orgConsole[level]; + }); + } + + // The test frame answers through its window + if (page.registry.testFrameContainer && page.registry.testFrameContainer.kids.length) { + page.frame().contentWindow = { postMessage: (msg: any) => page.framePosts.push(msg) }; + } + + return page; + } + + function posted(page: IPage, urlPart: string): any[] { + return page.fetches.filter((call) => call.url.indexOf(urlPart) !== -1 && call.init && call.init.method === "POST") + .map((call) => JSON.parse(call.init.body)); + } + + async function readyAndRun(page: IPage): Promise { + page.message({ kind: "ready", discovery: DISCOVERY }); + page.message({ kind: "lifecycleBatch", events: LIFECYCLE }); + page.message({ kind: "runDone", summary: { total: 2, passed: 1, failed: 1 }, coverage: null }); + await settle(); + } + + beforeEach(() => { + saved = {}; + GLOBALS.forEach((name) => { + saved[name] = g[name]; + }); + orgConsole = { log: console.log, info: console.info, warn: console.warn, error: console.error }; + pages = []; + }); + + afterEach(async () => { + // Anything the page left scheduled runs while its stand-ins are still there + await settle(); + + // A page sends what it logged to the CLI 50ms later, from a timer of its own -- by then the + // stand-ins below are gone, and with no location the log URL cannot be resolved (an unhandled + // rejection that ends the run). So each page sends it now, while they are still here. + for (let lp = 0; lp < pages.length; lp++) { + await pages[lp].logging.flushLogsNow(); + } + GLOBALS.forEach((name) => { + if (saved[name] === undefined) { + delete g[name]; + } else { + g[name] = saved[name]; + } + }); + }); + + describe("browser target", () => { + it("starts the test frame, hidden, and polls for changed files", () => { + const page = loadPage("/__tripwire__/browser"); + const frame = page.frame(); + + assert.equal(frame.tagName, "IFRAME"); + assert.equal(frame.id, "test-frame"); + assert.equal(frame.src, "http://localhost:4321/__tripwire__/test-frame?target=browser"); + assert.equal(frame.style.display, "none"); + assert.equal(page.status(), "Initializing..."); + assert.equal(page.intervals.length, 1); + assert.equal(page.registry.targetSelect.value, "browser"); + }); + + it("runs once the frame is ready, draws the results, and sends them to the CLI on Finish", async () => { + const page = loadPage("/__tripwire__/browser"); + + page.message({ kind: "ready", discovery: DISCOVERY }); + assert.deepEqual(page.framePosts, [ { kind: "run", selection: { suiteTitle: "", testTitle: "" } } ]); + assert.isTrue(page.state.running); + assert.equal(page.status(), "Running tests in browser mode..."); + + page.message({ kind: "lifecycleBatch", events: LIFECYCLE }); + assert.includes(textOf(page.registry.results), "frame suite"); + assert.includes(textOf(page.registry.results), "Test failed -> boom"); + + page.message({ kind: "runDone", summary: { total: 2 }, coverage: { a: 1 } }); + await settle(); + assert.equal(page.status(), "Run complete. You can run again or click Finish to return to CLI."); + assert.deepEqual(page.state.lastResult, { summary: { total: 2 }, coverage: { a: 1 } }); + + page.registry.finishBtn.click(); + await settle(); + assert.deepEqual(posted(page, "/api/result"), [ { summary: { total: 2 }, coverage: { a: 1 } } ]); + assert.equal(page.status(), "Completed. You can close this tab."); + + page.registry.finishBtn.click(); + await settle(); + assert.equal(posted(page, "/api/result").length, 1, "the result is only sent once"); + }); + + it("asks the frame to stop when Run is clicked mid-run, and runs again when idle", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + + page.registry.runBtn.click(); + assert.deepEqual(page.framePosts[1], { kind: "stop" }); + assert.equal(page.status(), "Stopping run..."); + + page.message({ kind: "runDone", summary: {} }); + await settle(); + assert.equal(page.status(), "Run stopped. You can run again or finish."); + + page.registry.runBtn.click(); + assert.deepEqual(page.framePosts[2], { kind: "run", selection: { suiteTitle: "", testTitle: "" } }); + }); + + it("logs a run the frame failed, and ignores results nobody is waiting for", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "runDone", summary: {} }); + page.message({ kind: "runError", error: "early" }); + page.message({ kind: "ready", discovery: DISCOVERY }); + + page.message({ kind: "runError" }); + await settle(); + + assert.equal(page.status(), "Run failed. See logs below."); + assert.includes(textOf(page.registry.logs), "Unknown frame error"); + }); + + it("draws progress sent one message at a time, logs what the frame logs, and ignores anything else", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + + LIFECYCLE.filter(Boolean).forEach((evt) => page.message(evt)); + page.message({ kind: "log", level: "warn", message: "from the frame" }); + page.message({ kind: "log" }); + page.message({ kind: "unknown" }); + page.listeners.message({}); + page.listeners.message(null); + await settle(); + + assert.includes(textOf(page.registry.results), "passes"); + assert.includes(textOf(page.registry.logs), "from the frame"); + }); + + it("runs a single test again from its rerun button, keeping the choice in the URL", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + const buttons = page.registry.results.querySelectorAll(".rerun-btn"); + const testButton = buttons.filter((btn: any) => btn.getAttribute("data-rerun-test") === "frame suite > passes")[0]; + + const evt = stubEvent("click"); + testButton.dispatchEvent(evt); + + assert.isTrue(evt.defaultPrevented); + assert.deepEqual(page.framePosts[page.framePosts.length - 1], { kind: "run", selection: { suiteTitle: "", testTitle: "frame suite > passes" } }); + assert.equal(page.registry.scopeSelect.value, "frame suite > passes"); + assert.equal(page.hashWrites.length, 1); + + const runs = page.framePosts.length; + testButton.click(); + assert.equal(page.framePosts.length, runs, "not while a run is going"); + page.message({ kind: "runDone", summary: {} }); + await settle(); + + buttons.filter((btn: any) => !btn.getAttribute("data-rerun-test"))[0].click(); + assert.deepEqual(page.framePosts[page.framePosts.length - 1].selection, { suiteTitle: "frame suite", testTitle: "" }); + + page.message({ kind: "lifecycleBatch", events: LIFECYCLE }); + page.message({ kind: "runDone", summary: {} }); + await settle(); + page.registry.results.querySelector(".test-label").click(); + page.registry.results.click(); + assert.equal(page.framePosts.length, runs + 1, "a click that is not on a rerun button runs nothing"); + }); + + it("restores the scope from the URL once the tests are found", () => { + const page = loadPage("/__tripwire__/browser", "", {}, { hash: "#test=" + hashSelectionValue("frame suite > passes") }); + + page.message({ kind: "ready", discovery: DISCOVERY }); + + assert.deepEqual(page.framePosts[0].selection, { suiteTitle: "", testTitle: "frame suite > passes" }); + }); + + it("puts the test frame in the body of a page without a frame container", () => { + const page = loadPage("/__tripwire__/browser", "", {}, { omit: [ "testFrameContainer" ] }); + + assert.equal(page.document.body.kids[0].id, "test-frame"); + }); + + it("offers the suites and tests it found as scope suggestions", () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + + page.registry.scopeSelect.value = "pass"; + page.registry.scopeSelect.dispatchEvent(stubEvent("input")); + + assert.includes(textOf(page.registry.scopeSelectList), "frame suite > passes"); + }); + + it("keeps Finish available when the result cannot be sent to the CLI", async () => { + const page = loadPage("/__tripwire__/browser", "", {}, { resultFails: true }); + await readyAndRun(page); + + page.registry.finishBtn.click(); + await settle(); + + assert.equal(page.status(), "Failed to send result to CLI. Check connection and retry."); + assert.isFalse(page.state.completed); + }); + + it("reloads the frame when the build changes, unless auto reload is off or a run is going", async () => { + const page = loadPage("/__tripwire__/browser"); + const poll = page.intervals[0]; + await readyAndRun(page); + const firstFrame = page.frame(); + + page.generation = 1; + poll(); + await settle(); + assert.equal(page.frame(), firstFrame, "the first answer is only remembered"); + + page.generation = "fail"; + poll(); + page.generation = "error"; + poll(); + await settle(); + assert.equal(page.frame(), firstFrame); + + page.registry.autoReloadToggle.checked = false; + page.registry.autoReloadToggle.dispatchEvent(stubEvent("change")); + page.generation = 2; + poll(); + await settle(); + assert.isFalse(page.state.autoReloadEnabled); + assert.equal(page.frame(), firstFrame, "auto reload is off"); + + page.registry.autoReloadToggle.checked = true; + page.registry.autoReloadToggle.dispatchEvent(stubEvent("change")); + poll(); + await settle(); + assert.notEqual(page.frame(), firstFrame); + assert.equal(page.registry.testFrameContainer.kids.length, 1, "the old frame is gone"); + assert.equal(page.status(), "Reloading tests..."); + }); + + it("reloads on the Reload button, giving up the run in progress", async () => { + const page = loadPage("/__tripwire__/browser"); + page.message({ kind: "ready", discovery: DISCOVERY }); + const firstFrame = page.frame(); + + page.registry.reloadBtn.click(); + await settle(); + + assert.isFalse(page.state.running); + assert.notEqual(page.frame(), firstFrame, "a new frame"); + }); + + it("clears the results and the logs, and filters to failed tests, from the toolbar", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + + page.registry.failedOnlyToggle.checked = true; + page.registry.failedOnlyToggle.dispatchEvent(stubEvent("change")); + page.registry.clearResultsBtn.click(); + page.registry.clearLogsBtn.click(); + + assert.equal(page.registry.results.kids.length, 0); + assert.equal(page.registry.assertCount.textContent, "0 assertions"); + }); + + it("switches to the other target, keeping the rest of the URL", () => { + const page = loadPage("/__tripwire__/browser", "?x=1"); + + page.registry.targetSelect.dispatchEvent(stubEvent("change")); + assert.equal(page.location.href, "http://localhost:4321/__tripwire__/browser?x=1", "the same target changes nothing"); + + page.registry.targetSelect.value = "worker"; + page.registry.targetSelect.dispatchEvent(stubEvent("change")); + assert.equal(page.location.href, "/__tripwire__/worker?x=1"); + assert.equal(page.status(), "Switching to worker target..."); + }); + + it("keeps the scope picker's choice in the URL, and clears it", async () => { + const page = loadPage("/__tripwire__/browser"); + await readyAndRun(page); + + page.registry.scopeSelect.value = "frame suite"; + page.registry.scopeSelect.dispatchEvent(stubEvent("change")); + assert.equal(page.hashWrites.length, 1); + + page.registry.clearScopeBtn.click(); + assert.equal(page.registry.scopeSelect.value, ""); + assert.equal(page.hashWrites.length, 2); + assert.equal(page.document.activeElement, page.registry.runBtn); + + page.registry.runBtn.click(); + page.registry.scopeSelect.value = "frame suite"; + page.registry.clearScopeBtn.click(); + assert.equal(page.registry.scopeSelect.value, "frame suite", "not while a run is going"); + }); + + it("sends the result to the CLI by itself when it finishes on its own", async () => { + const page = loadPage("/__tripwire__/browser", "?autofinish=1"); + + assert.equal(page.frame().src, "http://localhost:4321/__tripwire__/test-frame?target=browser&autofinish=1"); + assert.equal(page.intervals.length, 0, "nothing to reload"); + + await readyAndRun(page); + assert.deepEqual(posted(page, "/api/result"), [ { summary: { total: 2, passed: 1, failed: 1 }, coverage: null } ]); + }); + + it("sends a failed run to the CLI by itself when it finishes on its own", async () => { + const page = loadPage("/__tripwire__/browser", "?autofinish=1"); + + page.message({ kind: "ready", discovery: DISCOVERY }); + page.message({ kind: "runError", error: "frame crashed" }); + await settle(); + + assert.includes(posted(page, "/api/result")[0].error, "frame crashed"); + assert.isTrue(page.state.completed); + }); + + it("runs the frame's own code when it is the test frame", async () => { + const page = loadPage("/__tripwire__/test-frame", "", { "data-runner-mode": "frame" }); + await settle(); + + assert.equal(page.registry.testFrameContainer.kids.length, 0, "no frame inside the frame"); + assert.isTrue(page.parentPosts.length > 0, "it reports to the page above it"); + }); + + it("reports ready without looking for tests when the test frame finishes on its own", async () => { + const page = loadPage("/__tripwire__/test-frame", "?autofinish=1", { "data-runner-mode": "frame" }); + await settle(); + + assert.deepEqual(page.parentPosts[0], { kind: "ready", discovery: { suites: [], tests: [] } }); + }); + }); + + describe("worker target", () => { + it("discovers the tests in a worker, then runs them in another", async () => { + const page = loadPage("/__tripwire__/worker"); + await settle(); + + const discover = page.workers[0]; + assert.equal(discover.url, "http://localhost:4321/api/worker-script"); + assert.equal(discover.posted[0].kind, "discover"); + discover.onmessage({ data: { kind: "log", level: "info", message: "discovering" } }); + discover.onmessage({ data: { kind: "other" } }); + discover.onmessage({ data: { kind: "discovery", discovery: DISCOVERY } }); + discover.onmessage({ data: { kind: "discovery", discovery: DISCOVERY } }); + await settle(); + assert.isTrue(discover.terminated); + + const run = page.workers[1]; + assert.deepEqual(run.posted[0].selection, { suiteTitle: "", testTitle: "" }); + assert.isFalse(run.posted[0].autoFinish); + assert.equal(page.state.activeWorker, run); + + LIFECYCLE.filter(Boolean).forEach((evt) => run.onmessage({ data: evt })); + run.onmessage({ data: { kind: "log", message: "from the worker" } }); + run.onmessage({ data: { summary: { total: 2 } } }); + await settle(); + + assert.includes(textOf(page.registry.results), "frame suite"); + assert.includes(textOf(page.registry.logs), "from the worker"); + assert.includes(textOf(page.registry.logs), "discovering"); + assert.isNull(page.state.activeWorker); + assert.equal(page.status(), "Run complete. You can run again or click Finish to return to CLI."); + }); + + it("warns when discovery fails, and reports a worker that crashes", async () => { + const page = loadPage("/__tripwire__/worker"); + await settle(); + + page.workers[0].onmessage({ data: { kind: "discovery", error: "no such module" } }); + await settle(); + page.workers[1].onerror({ message: "worker crashed" }); + await settle(); + + assert.includes(textOf(page.registry.logs), "Test discovery failed, the Scope picker will be empty: no such module"); + assert.includes(textOf(page.registry.logs), "worker crashed"); + assert.equal(page.status(), "Run failed. See logs below."); + }); + + it("carries on without discovery when its worker errors", async () => { + const page = loadPage("/__tripwire__/worker"); + await settle(); + + page.workers[0].onerror({}); + await settle(); + page.workers[1].onerror(null); + await settle(); + + assert.includes(textOf(page.registry.logs), "Test discovery failed, the Scope picker will be empty: Worker execution failed."); + assert.includes(textOf(page.registry.logs), "Worker execution failed."); + }); + + it("reports that a worker cannot be started", async () => { + const page = loadPage("/__tripwire__/worker", "", {}); + page.workerThrows = "workers are blocked"; + page.registry.reloadBtn.click(); + await settle(); + + assert.includes(textOf(page.registry.logs), "Unable to discover tests: workers are blocked"); + assert.equal(page.status(), "Run failed. See logs below."); + }); + + it("skips discovery and the progress messages when it finishes on its own", async () => { + const page = loadPage("/__tripwire__/worker", "?autofinish=1"); + await settle(); + + const run = page.workers[0]; + assert.isTrue(run.posted[0].autoFinish); + LIFECYCLE.filter(Boolean).forEach((evt) => run.onmessage({ data: evt })); + run.onmessage({ data: { summary: { total: 2 } } }); + await settle(); + + assert.equal(page.registry.results.kids.length, 0, "nothing drawn"); + assert.deepEqual(posted(page, "/api/result"), [ { summary: { total: 2 } } ]); + }); + }); +}); diff --git a/internal/runner/test/src/ux/filtering.test.ts b/internal/runner/test/src/ux/filtering.test.ts index 1395a22..01004f3 100644 --- a/internal/runner/test/src/ux/filtering.test.ts +++ b/internal/runner/test/src/ux/filtering.test.ts @@ -9,6 +9,7 @@ import { assert } from "@nevware21/tripwire"; import { clearAllState, suiteState, testState } from "../../../src/ux/browser/ui/state"; import { getSuiteKeyChain, makeSuiteKey, makeTestKey } from "../../../src/shared/suiteNames"; +import { uxDom } from "../support/uxDom"; /** * The interactive page's "Failed only" view filter. @@ -23,94 +24,7 @@ import { getSuiteKeyChain, makeSuiteKey, makeTestKey } from "../../../src/shared * view no longer changes the result"). */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const filtering: typeof import("../../../src/ux/browser/ui/filtering") = require("../../../src/ux/browser/ui/filtering"); diff --git a/internal/runner/test/src/ux/frameMode.test.ts b/internal/runner/test/src/ux/frameMode.test.ts new file mode 100644 index 0000000..2abdc9b --- /dev/null +++ b/internal/runner/test/src/ux/frameMode.test.ts @@ -0,0 +1,245 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import * as tripwire from "@nevware21/tripwire"; +import { assert } from "@nevware21/tripwire"; +import { installDefineRegistry } from "../../../src/shared/moduleLoader"; +import { uxDom } from "../support/uxDom"; + +/** + * The test frame: the page the interactive runner loads in an iframe to run the browser target's tests. + * It fetches the run payload, reports the suites it finds to the page above it, runs them when asked, + * and streams their progress back in batches. Here its window, `fetch` and parent are stood in for, + * and the real tripwire runs the tests. + */ + +uxDom(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const frameMode: typeof import("../../../src/ux/browser/ui/frame-mode") = require("../../../src/ux/browser/ui/frame-mode"); + +describe("ux frame mode", function () { + this.timeout(30000); + + const g = globalThis as any; + const saved: { [name: string]: any } = {}; + let posted: any[]; + let listeners: { [type: string]: (evt: any) => void }; + let frameWindow: any; + let orgConsole: { [level: string]: any }; + + const PAYLOAD = { + tripwireModuleId: "/tripwire", + entryIds: [ "/test/a" ], + coverageOnlyIds: [ "/src/extra", "/not-in-the-bundle" ], + settings: { reporter: [ "dot" ] } + }; + + // The page's modules, as the frame's bundle would have defined them + function defineModules(win: any): void { + installDefineRegistry(win); + win.__tripwireDefine__("/tripwire", (_require: any, module: any) => { + module.exports = tripwire; + }); + win.__tripwireDefine__("/test/a", (require: any) => { + const tw = require("/tripwire"); + tw.describe("frame suite", () => { + tw.it("passes", () => { + tw.assert.equal(1, 1); + }); + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + }); + }); + win.__tripwireDefine__("/test/failFirst", (require: any) => { + const tw = require("/tripwire"); + tw.describe("fails first", () => { + tw.it("fails", () => { + tw.assert.equal(1, 2); + }); + tw.it("would pass", () => { + tw.assert.equal(1, 1); + }); + }); + }); + win.__tripwireDefine__("/src/extra", (_require: any, module: any) => { + module.exports = "loaded for coverage"; + }); + } + + async function waitFor(kind: string): Promise { + for (let lp = 0; lp < 500; lp++) { + const found = posted.filter((msg) => msg.kind === kind)[0]; + if (found) { + return found; + } + + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + throw new Error("the frame never posted " + kind + ": " + JSON.stringify(posted.map((msg) => msg.kind))); + } + + function lifecycle(): any[] { + const events: any[] = []; + posted.filter((msg) => msg.kind === "lifecycleBatch").forEach((msg) => events.push(...msg.events)); + + return events; + } + + async function initialize(payload: any, fetchError?: Error): Promise { + g.fetch = (url: string) => { + if (url !== "http://localhost:4321/api/payload-browser") { + return Promise.reject(new Error("unexpected fetch " + url)); + } + + return fetchError ? Promise.reject(fetchError) : Promise.resolve({ json: () => Promise.resolve(payload) }); + }; + + await frameMode.frameInitialize(); + } + + beforeEach(() => { + posted = []; + listeners = {}; + [ "window", "fetch", "location" ].forEach((name) => { + saved[name] = g[name]; + }); + + frameWindow = { + parent: { postMessage: (msg: any, origin: string) => posted.push(Object.assign({ origin: origin }, msg)) }, + addEventListener: (type: string, fn: (evt: any) => void) => { + listeners[type] = fn; + } + }; + defineModules(frameWindow); + g.window = frameWindow; + g.location = { href: "http://localhost:4321/__tripwire__/test-frame" }; + + // The frame sends the console to the page for good + orgConsole = { log: console.log, info: console.info, warn: console.warn, error: console.error }; + }); + + afterEach(() => { + Object.keys(orgConsole).forEach((level) => { + (console as any)[level] = orgConsole[level]; + }); + Object.keys(saved).forEach((name) => { + if (saved[name] === undefined) { + delete g[name]; + } else { + g[name] = saved[name]; + } + }); + }); + + it("tells the page it is ready, with the suites and tests it found", async () => { + await initialize(PAYLOAD); + + const ready = posted[0]; + assert.equal(ready.kind, "ready"); + assert.equal(ready.origin, "*"); + assert.includes(JSON.stringify(ready.discovery), "frame suite"); + assert.includes(JSON.stringify(ready.discovery), "fails"); + assert.isFunction(listeners.message); + }); + + it("runs the tests when asked, streaming their progress and then the results", async () => { + await initialize(PAYLOAD); + frameWindow.__coverage__ = { "a.js": {} }; + + listeners.message({ data: { kind: "run", selection: {} } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.total, 2); + assert.equal(done.summary.passed, 1); + assert.equal(done.summary.failed, 1); + assert.deepEqual(done.coverage, { "a.js": {} }); + + const events = lifecycle(); + assert.deepEqual(events.filter((evt) => evt.kind === "testStart").map((evt) => evt.testName), [ "passes", "fails" ]); + assert.deepEqual(events.filter((evt) => evt.kind === "testDone").map((evt) => [ evt.testName, evt.endStatus ]), [ [ "passes", "passed" ], [ "fails", "failed" ] ]); + assert.includes(events.filter((evt) => evt.kind === "testDone")[1].endErrorText, "expected 1 to equal 2"); + assert.isTrue(events.filter((evt) => evt.kind === "suiteStart").length >= 1); + assert.isTrue(events.filter((evt) => evt.kind === "suiteDone").length >= 1); + + const asserts = events.filter((evt) => evt.kind === "assert"); + assert.isTrue(asserts[0].passed); + assert.includes(asserts.filter((evt) => !evt.passed)[0].errorText, "expected 1 to equal 2"); + }); + + it("sends the reporter's console output to the page", async () => { + await initialize(Object.assign({}, PAYLOAD, { settings: { reporter: [ "spec" ], slow: 10, trace: true } })); + + listeners.message({ data: { kind: "run" } }); + await waitFor("runDone"); + + const logged = posted.filter((msg) => msg.kind === "log").map((msg) => msg.message).join("\n"); + assert.includes(logged, "frame suite"); + }); + + it("runs only the selected test", async () => { + await initialize(PAYLOAD); + + listeners.message({ data: { kind: "run", selection: { testTitle: "frame suite > passes" } } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.passed, 1); + assert.equal(done.summary.failed, 0); + }); + + it("stops at the first failure with bail", async () => { + await initialize(Object.assign({}, PAYLOAD, { entryIds: [ "/test/failFirst" ], settings: { reporter: [ "dot" ], bail: true } })); + + listeners.message({ data: { kind: "run" } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.failed, 1); + assert.equal(done.summary.passed, 0, "nothing runs after the failure"); + }); + + it("stops a run the page asks it to stop", async () => { + await initialize(PAYLOAD); + + listeners.message({ data: { kind: "run" } }); + listeners.message({ data: { kind: "stop" } }); + const done = await waitFor("runDone"); + + assert.equal(done.summary.passed, 0, "nothing ran once stopped"); + }); + + it("ignores a message that is not for it", async () => { + await initialize(PAYLOAD); + + listeners.message({ data: null }); + listeners.message({ data: { other: true } }); + + assert.deepEqual(posted.map((msg) => msg.kind), [ "ready" ]); + }); + + it("reports a payload it cannot fetch", async () => { + await initialize(PAYLOAD, new Error("connection refused")); + + assert.equal(posted[0].kind, "runError"); + assert.includes(posted[0].error, "connection refused"); + }); + + it("reports the module scripts that failed to load, before or during a run", async () => { + frameWindow.__tripwireModuleLoadErrors__ = [ "/test/broken.js" ]; + await initialize(PAYLOAD); + assert.includes(posted[0].error, "Failed to load 1 module script(s): /test/broken.js"); + + posted = []; + delete frameWindow.__tripwireModuleLoadErrors__; + await initialize(PAYLOAD); + frameWindow.__tripwireModuleLoadErrors__ = [ "/test/late.js" ]; + listeners.message({ data: { kind: "run" } }); + + assert.includes((await waitFor("runError")).error, "Failed to load 1 module script(s): /test/late.js"); + }); +}); diff --git a/internal/runner/test/src/ux/logging.test.ts b/internal/runner/test/src/ux/logging.test.ts index a417287..f00b74f 100644 --- a/internal/runner/test/src/ux/logging.test.ts +++ b/internal/runner/test/src/ux/logging.test.ts @@ -10,6 +10,7 @@ import { assert } from "@nevware21/tripwire"; import { logDomQueue, logSendQueue } from "../../../src/ux/browser/ui/state"; import { MAX_LOG_LINES } from "../../../src/ux/browser/ui/shared"; import { safeToString } from "../../../src/ux/browser/ui/utils"; +import { stubElement, uxDom } from "../support/uxDom"; /** * The interactive page's log panel and its mirror to the node console: every entry is queued @@ -17,97 +18,10 @@ import { safeToString } from "../../../src/ux/browser/ui/utils"; * trimmed past `MAX_LOG_LINES`) and once for the server (POSTed to /api/log in batches, with at * most one request in flight). Node has no `requestAnimationFrame`, `fetch` or `location`, so * each test installs a recording stand-in for the three and takes them away again; the DOM the - * flush writes into is a plain object (see `_uxDom`). + * flush writes into is a plain object (see `uxDom` in ../support/uxDom.ts). */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const logging: typeof import("../../../src/ux/browser/ui/logging") = require("../../../src/ux/browser/ui/logging"); @@ -174,7 +88,7 @@ function _drainFrames(): void { frames = []; g.document = { - createElement: (tag: string) => _stubElement(tag), + createElement: (tag: string) => stubElement(tag), createDocumentFragment: () => { const fragment: any = { fragment: true, kids: [] as any[] }; fragment.appendChild = (child: any) => { diff --git a/internal/runner/test/src/ux/menu.test.ts b/internal/runner/test/src/ux/menu.test.ts index d382092..093d9a0 100644 --- a/internal/runner/test/src/ux/menu.test.ts +++ b/internal/runner/test/src/ux/menu.test.ts @@ -7,18 +7,176 @@ */ import { assert } from "@nevware21/tripwire"; -import { nextMenuIndex } from "../../../src/ux/browser/ui/menu"; +import { createMenu, nextMenuIndex } from "../../../src/ux/browser/ui/menu"; +import { stubElement, stubEvent, useStubDocument } from "../support/uxDom"; /** - * The arrow-key movement behind the interactive page's "..." actions menu. - * - * `createMenu` itself needs a live document and is exercised end to end against the real page; - * this is the part of it that is pure, and the part that breaks silently -- an off-by-one in the - * wrapping leaves a keyboard user stuck at one end of the menu with no visible sign anything is - * wrong. + * The interactive page's "..." actions menu: the arrow-key movement, which breaks silently -- an + * off-by-one in the wrapping leaves a keyboard user stuck at one end of the menu with no visible + * sign anything is wrong -- and the open, close and focus handling around it, run against the + * stand-in elements and `document` of ../support/uxDom.ts. It is also exercised end to end against + * the real page. */ describe("ux menu", () => { + describe("createMenu", () => { + let doc: any; + let restoreDocument: () => void; + let button: any; + let menu: any; + let items: any[]; + + function key(target: any, name: string): any { + const evt = stubEvent("keydown", { key: name }); + target.dispatchEvent(evt); + + return evt; + } + + function isOpen(): boolean { + return !menu.hasAttribute("hidden"); + } + + beforeEach(() => { + const installed = useStubDocument(); + doc = installed.document; + restoreDocument = installed.restore; + + button = stubElement("actionsBtn", "button"); + menu = stubElement("actionsMenu", "div"); + menu.setAttribute("hidden", ""); + items = [ "Run", "Finish", "Reload" ].map((label) => { + const item = stubElement("", "button"); + item.setAttribute("role", "menuitem"); + item.textContent = label; + menu.appendChild(item); + + return item; + }); + items[1].setAttribute("aria-disabled", "true"); + createMenu(button, menu); + }); + + afterEach(() => { + restoreDocument(); + }); + + it("opens on a click, focusing the first item, and closes on the next", () => { + const evt = stubEvent("click"); + button.dispatchEvent(evt); + + assert.isTrue(isOpen()); + assert.isTrue(evt.defaultPrevented); + assert.equal(button.getAttribute("aria-expanded"), "true"); + assert.equal(doc.activeElement, items[0]); + assert.deepEqual(items.map((item) => item.getAttribute("tabindex")), [ "0", "-1", "-1" ]); + + button.click(); + assert.isFalse(isOpen()); + assert.equal(button.getAttribute("aria-expanded"), "false"); + }); + + it("opens from the button's keys at the first or the last item, and closes on Escape", () => { + [ "ArrowDown", "Enter", " " ].forEach((name) => { + menu.setAttribute("hidden", ""); + assert.isTrue(key(button, name).defaultPrevented, name); + assert.isTrue(isOpen(), name); + assert.equal(doc.activeElement, items[0], name); + }); + + menu.setAttribute("hidden", ""); + key(button, "ArrowUp"); + assert.equal(doc.activeElement, items[2]); + + key(button, "Escape"); + assert.isFalse(isOpen()); + + key(button, "Tab"); + assert.isFalse(isOpen(), "other keys do nothing"); + }); + + it("moves through the items with the arrow, Home and End keys", () => { + button.click(); + + key(menu, "ArrowDown"); + assert.equal(doc.activeElement, items[1]); + key(menu, "End"); + assert.equal(doc.activeElement, items[2]); + key(menu, "ArrowDown"); + assert.equal(doc.activeElement, items[0], "wraps round"); + key(menu, "ArrowUp"); + assert.equal(doc.activeElement, items[2]); + key(menu, "Home"); + assert.equal(doc.activeElement, items[0]); + + assert.isFalse(key(menu, "a").defaultPrevented, "other keys are left alone"); + assert.isTrue(isOpen()); + }); + + it("closes on Escape, giving focus back to the button, and on Tab without", () => { + button.click(); + const evt = key(menu, "Escape"); + + assert.isFalse(isOpen()); + assert.isTrue(evt.defaultPrevented); + assert.equal(doc.activeElement, button); + + button.click(); + doc.activeElement = items[0]; + key(menu, "Tab"); + assert.isFalse(isOpen()); + assert.equal(doc.activeElement, items[0]); + + key(menu, "Escape"); + assert.isFalse(isOpen(), "closing a closed menu changes nothing"); + }); + + it("closes when an item is chosen, but not for a disabled one", () => { + button.click(); + const disabled = stubEvent("click"); + items[1].dispatchEvent(disabled); + + assert.isTrue(isOpen()); + assert.isTrue(disabled.defaultPrevented); + assert.isTrue(disabled.propagationStopped); + + items[2].click(); + assert.isFalse(isOpen()); + assert.equal(doc.activeElement, button); + }); + + it("stays open for a click inside it that is not on an item", () => { + const gap = stubElement("", "div"); + menu.appendChild(gap); + button.click(); + + gap.click(); + + assert.isTrue(isOpen()); + }); + + it("closes for a click anywhere else on the page", () => { + button.click(); + + stubElement("", "div").click(); + + assert.isFalse(isOpen()); + }); + + it("closes once focus has left both the menu and the button", async () => { + button.click(); + doc.activeElement = items[0]; + menu.dispatchEvent(stubEvent("focusout")); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.isTrue(isOpen(), "focus is still inside"); + + doc.activeElement = stubElement("", "input"); + menu.dispatchEvent(stubEvent("focusout")); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.isFalse(isOpen()); + }); + }); + describe("nextMenuIndex", () => { it("should step down through the items", () => { assert.equal(nextMenuIndex(4, 0, "ArrowDown"), 1); diff --git a/internal/runner/test/src/ux/rendering.test.ts b/internal/runner/test/src/ux/rendering.test.ts new file mode 100644 index 0000000..e9316d6 --- /dev/null +++ b/internal/runner/test/src/ux/rendering.test.ts @@ -0,0 +1,400 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire"; +import { + assertionDomQueue, assertionFlushScheduled, clearAllState, setAssertionFlushScheduled, setHighlightSelection, suiteState, testState +} from "../../../src/ux/browser/ui/state"; +import { MAX_ASSERTIONS_PER_TEST } from "../../../src/ux/browser/ui/shared"; +import { textOf, useStubDocument, uxDom } from "../support/uxDom"; + +/** + * How the interactive page draws a run: a collapsible node per suite and per test under the results + * panel, each with its status, duration, counts and assertions, and how the progress the runner + * reports moves them along. The page's elements are the plain objects of ../support/uxDom.ts, and a + * stand-in `document` builds the new ones. + */ + +const dom = uxDom(); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const rendering: typeof import("../../../src/ux/browser/ui/ui-rendering") = require("../../../src/ux/browser/ui/ui-rendering"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const execution: typeof import("../../../src/ux/browser/ui/test-execution") = require("../../../src/ux/browser/ui/test-execution"); + +function noFilter(): void { + // The filtering module decides visibility; it is tested on its own +} + +/** A stand-in element's children. */ +function kids(el: any): any[] { + return el.kids; +} + +/** The options each `scrollIntoView` call on a stand-in element was given. */ +function scrolled(el: any): any[] { + return el.scrolledIntoView; +} + +/** The element whose class list contains `cls`, anywhere under `el`. */ +function find(el: any, cls: string): any { + return el.querySelector("." + cls); +} + +describe("ux rendering", () => { + let restoreDocument: () => void; + + beforeEach(() => { + restoreDocument = useStubDocument(dom).restore; + rendering.clearResults(dom.assertCount); + clearAllState(); + setHighlightSelection("", ""); + dom.results.scrollTop = 0; + dom.results.clientHeight = 0; + dom.results.scrollHeight = 0; + }); + + afterEach(() => { + restoreDocument(); + }); + + describe("suite nodes", () => { + it("draws a running suite under the results panel", () => { + const suite = rendering.ensureSuiteNode([ "outer" ]); + + assert.equal(kids(dom.results)[0], suite.details); + assert.isTrue(suite.details.open); + assert.isTrue(suite.details.classList.contains("suite-running")); + assert.equal(textOf(find(suite.details, "suite-name")), "outer"); + assert.equal(suite.meta.textContent, "0/0 tests"); + assert.equal(suite.chip.textContent, "running"); + assert.equal(suite.marker.getAttribute("aria-hidden"), "true"); + assert.equal(find(suite.details, "rerun-btn").getAttribute("data-rerun-suite"), "outer"); + assert.equal(rendering.ensureSuiteNode([ "outer" ]), suite, "drawn once"); + }); + + it("nests a suite in its parent, counting it there", () => { + const inner = rendering.ensureSuiteNode([ "outer", "inner" ]); + const outer = suiteState["outer"]; + + assert.equal(kids(outer.children)[0], inner.details); + assert.equal(outer.childSuiteCount, 1); + assert.equal(outer.meta.textContent, "0/0 tests • 1 child suite"); + + rendering.ensureSuiteNode([ "outer", "second" ]); + assert.equal(outer.meta.textContent, "0/0 tests • 2 child suites"); + }); + + it("draws the default suite without a rerun button", () => { + const root = rendering.ensureSuiteNode([]); + + assert.equal(textOf(find(root.details, "suite-name")), "(default suite)"); + assert.isNull(find(root.details, "rerun-btn")); + }); + + it("marks a suite's status", () => { + const suite = rendering.ensureSuiteNode([ "marked" ]); + const marks: any = {}; + + [ "passed", "failed", "pending", "running" ].forEach((status) => { + rendering.setSuiteMarker(suite, status); + marks[status] = [ suite.marker.className, suite.marker.textContent ]; + }); + + assert.deepEqual(marks, { + passed: [ "suite-marker marker-passed", "✓" ], + failed: [ "suite-marker marker-failed", "✗" ], + pending: [ "suite-marker marker-pending", "○" ], + running: [ "suite-marker marker-running", "" ] + }); + + rendering.setSuiteMarker(null as any, "passed"); + rendering.updateSuiteMeta(null as any); + }); + }); + + describe("test nodes", () => { + it("draws a running test in its suite, counting it in every enclosing suite", () => { + const test = rendering.ensureTestNode([ "outer", "inner" ], "works"); + + assert.equal(kids(suiteState["outer > inner"].children)[0], test.details); + assert.equal(test.label.textContent, "… works"); + assert.equal(test.chip.textContent, "running"); + assert.equal(find(test.details, "rerun-btn").getAttribute("data-rerun-test"), "outer > inner > works"); + assert.equal(suiteState["outer"].total, 1); + assert.equal(suiteState["outer > inner"].meta.textContent, "0/1 tests"); + assert.equal(rendering.ensureTestNode([ "outer", "inner" ], "works"), test, "drawn once"); + }); + + it("names a test without a name", () => { + assert.equal(rendering.ensureTestNode([], "").testName, "(unnamed test)"); + }); + + it("collapses a passed test and keeps a failed one open, with its duration", () => { + const passed = rendering.ensureTestNode([ "s" ], "passes"); + const failed = rendering.ensureTestNode([ "s" ], "fails"); + const filtered: any[] = []; + passed.durationMs = 12; + + rendering.setTestStatus(passed, "passed", (node) => filtered.push(node)); + rendering.setTestStatus(failed, "failed", (node) => filtered.push(node)); + + assert.isFalse(passed.details.open); + assert.equal(passed.label.textContent, "✓ passes"); + assert.equal(passed.chip.className, "status-chip status-passed"); + assert.isTrue(passed.details.classList.contains("test-passed")); + assert.isFalse(passed.details.classList.contains("test-running")); + assert.equal(passed.duration.textContent, "12ms"); + assert.equal(passed.duration.style.display, "inline-flex"); + assert.isTrue(failed.details.open); + assert.equal(failed.duration.style.display, "none"); + assert.deepEqual(filtered, [ passed, failed ]); + }); + + it("keeps the highlighted test open and scrolls to it", () => { + setHighlightSelection("s", "s > chosen"); + const chosen = rendering.ensureTestNode([ "s" ], "chosen"); + + rendering.setTestStatus(chosen, "passed", noFilter); + + assert.isTrue(chosen.details.open); + assert.deepEqual(scrolled(chosen.details), [ { block: "center" } ]); + }); + }); + + describe("suite status", () => { + it("shows a suite as running until all its tests are done", () => { + rendering.ensureTestNode([ "s" ], "a"); + const suite = suiteState["s"]; + + rendering.updateSuiteStatus("s"); + + assert.equal(suite.chip.textContent, "running 0/1"); + assert.equal(suite.marker.className, "suite-marker marker-running"); + rendering.updateSuiteStatus("no such suite"); + }); + + it("opens a failed suite and collapses a passed one", () => { + const failing = rendering.ensureSuiteNode([ "failing" ]); + const passing = rendering.ensureSuiteNode([ "passing" ]); + failing.failed = 1; + passing.durationMs = 5; + + rendering.updateSuiteStatus("failing"); + rendering.updateSuiteStatus("passing"); + + assert.equal(failing.chip.textContent, "failed"); + assert.isTrue(failing.details.open); + assert.isTrue(failing.details.classList.contains("suite-failed")); + assert.equal(passing.chip.textContent, "passed"); + assert.isFalse(passing.details.open); + assert.equal(passing.duration.textContent, "5ms"); + }); + + it("keeps a passed suite open on the way to the highlighted test, and scrolls to a highlighted suite", () => { + setHighlightSelection("outer > inner", ""); + const outer = rendering.ensureSuiteNode([ "outer" ]); + const inner = rendering.ensureSuiteNode([ "outer", "inner" ]); + + rendering.updateSuiteStatus("outer"); + rendering.updateSuiteStatus("outer > inner"); + + assert.isTrue(outer.details.open, "an ancestor of the selection"); + assert.deepEqual(scrolled(outer.details), []); + assert.isTrue(inner.details.open); + assert.deepEqual(scrolled(inner.details), [ { block: "center" } ]); + }); + + it("keeps the default suite open for a highlighted test of its own", () => { + setHighlightSelection("", "top level test"); + const root = rendering.ensureSuiteNode([]); + + rendering.updateSuiteStatus(root.key); + + assert.isTrue(root.details.open); + }); + }); + + describe("assertions", () => { + it("draws the queued assertions under their tests, and the running total", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + assertionDomQueue.push( + { testKey: test.key, passed: true, label: "equal(1, 1)", errorText: "" }, + { testKey: test.key, passed: false, label: "equal(1, 2)", errorText: "expected 1 to equal 2" }, + { testKey: "a test that is not drawn", passed: true, label: "x", errorText: "" }, + null + ); + setAssertionFlushScheduled(true); + + rendering.flushAssertionDomQueue(dom.assertCount); + + assert.deepEqual(kids(test.assertions).map((item: any) => [ item.className, item.textContent ]), [ + [ "assert-item assert-pass", "✓ equal(1, 1)" ], + [ "assert-item assert-fail", "✗ equal(1, 2) -> expected 1 to equal 2" ] + ]); + assert.equal(test.assertionCount, 2); + assert.equal(dom.assertCount.textContent, "2 assertions"); + assert.equal(assertionDomQueue.length, 0); + assert.isFalse(assertionFlushScheduled); + }); + + it("keeps only the latest assertions of a very long test", () => { + const test = rendering.ensureTestNode([ "s" ], "long"); + for (let lp = 0; lp <= MAX_ASSERTIONS_PER_TEST; lp++) { + assertionDomQueue.push({ testKey: test.key, passed: true, label: "check " + lp, errorText: "" }); + } + + rendering.flushAssertionDomQueue(null); + + assert.equal(test.assertions.childElementCount, MAX_ASSERTIONS_PER_TEST); + assert.equal(kids(test.assertions)[0].textContent, "✓ check 1", "the first was dropped"); + assert.equal(test.assertionCount, MAX_ASSERTIONS_PER_TEST + 1); + }); + + it("only clears the flag when nothing is queued", () => { + setAssertionFlushScheduled(true); + + rendering.flushAssertionDomQueue(dom.assertCount); + + assert.isFalse(assertionFlushScheduled); + }); + + it("adds an assertion of its own for a test that reported none", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + + rendering.appendSyntheticAssertion(test, false, "", "boom", dom.assertCount); + + assert.equal(kids(test.assertions)[0].textContent, "✗ (assertion) -> boom"); + assert.equal(dom.assertCount.textContent, "1 assertion"); + rendering.appendSyntheticAssertion(null as any, true, "x", "", dom.assertCount); + }); + + it("drops the oldest synthetic assertion past the limit", () => { + const test = rendering.ensureTestNode([ "s" ], "t"); + for (let lp = 0; lp <= MAX_ASSERTIONS_PER_TEST; lp++) { + rendering.appendSyntheticAssertion(test, true, "synthetic " + lp, "", null); + } + + assert.equal(test.assertions.childElementCount, MAX_ASSERTIONS_PER_TEST); + }); + }); + + describe("scrolling", () => { + it("follows new results while the panel is scrolled to the bottom, for a whole batch", () => { + dom.results.clientHeight = 100; + dom.results.scrollHeight = 100; + + rendering.beginRenderBatch(); + rendering.ensureSuiteNode([ "a" ]); + dom.results.scrollHeight = 400; + rendering.ensureTestNode([ "a" ], "t"); + rendering.endRenderBatch(); + + assert.equal(dom.results.scrollTop, 400); + }); + + it("leaves the panel where it is when it was scrolled up", () => { + dom.results.clientHeight = 100; + dom.results.scrollHeight = 1000; + + rendering.ensureSuiteNode([ "a" ]); + + assert.equal(dom.results.scrollTop, 0); + rendering.endRenderBatch(); + }); + }); + + it("clears every drawn result and the counts", () => { + rendering.ensureTestNode([ "s" ], "t"); + assertionDomQueue.push({ testKey: "x", passed: true, label: "", errorText: "" }); + + rendering.clearResults(dom.assertCount); + + assert.deepEqual(Object.keys(suiteState), []); + assert.deepEqual(Object.keys(testState), []); + assert.equal(assertionDomQueue.length, 0); + assert.equal(kids(dom.results).length, 0); + assert.equal(dom.assertCount.textContent, "0 assertions"); + rendering.clearResults(null); + }); + + describe("test execution", () => { + it("finds the most recent running test", () => { + assert.equal(execution.findActiveTestKey(), ""); + + const first = execution._handleTestStart([ "s" ], "first", noFilter); + const second = execution._handleTestStart([ "s" ], "second", noFilter); + assert.equal(execution.findActiveTestKey(), second.key); + + execution._handleTestDone([ "s" ], "second", "passed", 1, "", noFilter); + assert.equal(execution.findActiveTestKey(), first.key); + }); + + it("draws a suite as it starts, and its status and duration as it ends", () => { + execution._handleSuiteStart([ "suite" ]); + execution._handleSuiteDone([ "suite" ], 7); + + const suite = suiteState["suite"]; + assert.equal(suite.chip.textContent, "passed"); + assert.equal(suite.duration.textContent, "7ms"); + + execution._handleSuiteStart(null); + execution._handleSuiteDone(null, 0); + assert.isObject(suiteState["(default suite)"]); + }); + + it("counts each finished test once, in every enclosing suite", () => { + execution._handleTestStart([ "outer", "inner" ], "t", noFilter); + + execution._handleTestDone([ "outer", "inner" ], "t", "failed", 3, "boom", noFilter); + execution._handleTestDone([ "outer", "inner" ], "t", "failed", 3, "boom", noFilter); + + assert.equal(suiteState["outer"].done, 1); + assert.equal(suiteState["outer"].failed, 1); + assert.equal(suiteState["outer > inner"].chip.textContent, "failed"); + }); + + it("treats skipped and pending tests as passed", () => { + const skipped = execution._handleTestDone([ "s" ], "skipped", "skipped", 0, "", noFilter); + const pending = execution._handleTestDone([ "s" ], "pending", "", 0, "", noFilter); + + assert.equal(skipped.status, "passed"); + assert.equal(pending.status, "passed"); + assert.equal(suiteState["s"].failed, 0); + }); + + it("adds a failure of its own to a failed test that reported no assertion", () => { + const failed = execution._handleTestDone([ "s" ], "failed", "failed", 0, "expected true", noFilter); + const timedOut = execution._handleTestDone([ "s" ], "slow", "failed", 0, "Timeout of 2000ms exceeded", noFilter); + const silent = execution._handleTestDone([ "s" ], "silent", "failed", 0, null, noFilter); + + assert.equal(kids(failed.assertions)[0].textContent, "✗ Test failed -> expected true"); + assert.equal(kids(timedOut.assertions)[0].textContent, "✗ Test timed out -> Timeout of 2000ms exceeded"); + assert.equal(kids(silent.assertions)[0].textContent, "✗ Test failed"); + }); + + it("leaves a failed test's own assertions, drawn or still queued, to explain it", () => { + const drawn = execution._handleTestStart([ "s" ], "drawn", noFilter); + rendering.appendSyntheticAssertion(drawn, false, "equal", "no", null); + const queued = execution._handleTestStart([ "s" ], "queued", noFilter); + assertionDomQueue.push({ testKey: queued.key, passed: false, label: "equal", errorText: "no" }); + + execution._handleTestDone([ "s" ], "drawn", "failed", 0, "no", noFilter); + execution._handleTestDone([ "s" ], "queued", "failed", 0, "no", noFilter); + + assert.equal(kids(drawn.assertions).length, 1); + assert.equal(kids(queued.assertions).length, 0); + }); + + it("names unnamed tests and suites", () => { + const node = execution._handleTestStart(null, null, noFilter); + + assert.equal(node.testName, "(unnamed test)"); + assert.equal(execution._handleTestDone(null, null, "passed", 0, "", noFilter), node); + }); + }); +}); diff --git a/internal/runner/test/src/ux/selection.test.ts b/internal/runner/test/src/ux/selection.test.ts index bb1ff41..84e9b4c 100644 --- a/internal/runner/test/src/ux/selection.test.ts +++ b/internal/runner/test/src/ux/selection.test.ts @@ -9,6 +9,7 @@ import { assert } from "@nevware21/tripwire"; import { AUTOCOMPLETE_KIND_ATTR } from "../../../src/ux/browser/ui/autocomplete"; import { ALL_SCOPE_LABEL } from "../../../src/shared/scopeOptions"; +import { uxDom } from "../support/uxDom"; /** * The half of the Scope picker that reads and writes the live input element. The searching and @@ -18,94 +19,7 @@ import { ALL_SCOPE_LABEL } from "../../../src/shared/scopeOptions"; * picked), and that restoring a selection that no longer exists leaves the picker blank. */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const selection: typeof import("../../../src/ux/browser/ui/selection") = require("../../../src/ux/browser/ui/selection"); diff --git a/internal/runner/test/src/ux/ui-control.test.ts b/internal/runner/test/src/ux/ui-control.test.ts index 1e501c5..14db9d4 100644 --- a/internal/runner/test/src/ux/ui-control.test.ts +++ b/internal/runner/test/src/ux/ui-control.test.ts @@ -8,102 +8,16 @@ import { assert } from "@nevware21/tripwire"; import { clearAllState, setCompleted, setLastResult, setPayload, setRunning } from "../../../src/ux/browser/ui/state"; +import { uxDom } from "../support/uxDom"; /** * The interactive page's toolbar controls as a function of the run state: what the Run button * says and whether it is enabled, when Finish is available, and which pickers are locked while a - * run is in flight. The elements are plain objects (see `_uxDom`), since all the module does to - * them is set `disabled`, `textContent`, a class and an attribute. + * run is in flight. The elements are plain objects (see `uxDom` in ../support/uxDom.ts), since all + * the module does to them is set `disabled`, `textContent`, a class and an attribute. */ -/** - * `./dom` looks every element it exports up from `document` once, as it loads, and node has no - * `document`. This hands it the smallest stand-in that lets it load -- one plain object per id, - * created on demand -- and takes `document` away again straight afterwards so nothing else in the - * process can mistake this for a browser. The objects live in a registry on `globalThis` because - * `./dom` loads once per process, for whichever test file gets there first: every file that needs - * them has to read the same objects back, whoever created them. - */ -function _uxDom(): { [id: string]: any } { - const g = globalThis as any; - const registry = g.__tripwireUxDomStub || (g.__tripwireUxDomStub = {}); - const hadDocument = !!g.document; - - if (!hadDocument) { - g.document = { - body: null, - getElementById: (id: string) => registry[id] || (registry[id] = _stubElement(id)) - }; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require("../../../src/ux/browser/ui/dom"); - } finally { - if (!hadDocument) { - delete g.document; - } - } - - return registry; -} - -/** A plain object with the handful of element members the ui modules under test touch. */ -function _stubElement(id: string): any { - const attrs: { [name: string]: string } = {}; - const classes: string[] = []; - let text = ""; - const el: any = { - id: id, - value: "", - checked: false, - disabled: false, - style: {}, - attrs: attrs, - kids: [] as any[], - setAttribute: (name: string, value: any) => { - attrs[name] = String(value); - }, - getAttribute: (name: string) => (name in attrs ? attrs[name] : null), - removeAttribute: (name: string) => { - delete attrs[name]; - }, - classList: { - add: (cls: string) => { - if (classes.indexOf(cls) === -1) { - classes.push(cls); - } - }, - remove: (cls: string) => { - const idx = classes.indexOf(cls); - if (idx !== -1) { - classes.splice(idx, 1); - } - }, - contains: (cls: string) => classes.indexOf(cls) !== -1 - }, - appendChild: (child: any) => { - el.kids.push.apply(el.kids, child && child.fragment ? child.kids : [child]); - }, - removeChild: (child: any) => { - el.kids.splice(el.kids.indexOf(child), 1); - } - }; - - Object.defineProperty(el, "textContent", { - get: () => text, - set: (value: string) => { - text = value; - el.kids.length = 0; - } - }); - Object.defineProperty(el, "childElementCount", { get: () => el.kids.length }); - Object.defineProperty(el, "firstElementChild", { get: () => el.kids[0] || null }); - - return el; -} - -const dom = _uxDom(); +const dom = uxDom(); // eslint-disable-next-line @typescript-eslint/no-var-requires const uiControl: typeof import("../../../src/ux/browser/ui/ui-control") = require("../../../src/ux/browser/ui/ui-control"); diff --git a/shim/chai/rollup.config.js b/shim/chai/rollup.config.js index 9e37726..43708ac 100644 --- a/shim/chai/rollup.config.js +++ b/shim/chai/rollup.config.js @@ -176,6 +176,20 @@ const rollupConfigMainEntry = (srcPath, destPath, path, format = "umd") => { return taskRollupConfig; }; +// A circular import between this repository's own modules fails the build: which module's exports +// exist when another reads them then depends on which file happens to load first. A cycle entirely +// inside a third-party package is left as a warning, since it is not ours to fix. +function failOnCircularImport(warning, warn) { + if (warning.code === "CIRCULAR_DEPENDENCY") { + const ids = warning.ids || []; + if (!ids.some((id) => /[\\/]node_modules[\\/]/.test(id))) { + throw new Error("Circular import: " + (ids.length ? ids.join(" -> ") : warning.message)); + } + } + + warn(warning); +} + export default [ rollupConfigMainEntry("build/es5/mod", "dist/es5", "main", "umd"), rollupConfigMainEntry("build/es6/mod", "dist/es6", "main", "umd"), @@ -196,4 +210,4 @@ export default [ rollupConfigFactory("build/es6/mod", "bundle/es6", true, "iife", "iife"), rollupConfigFactory("build/es6/mod", "bundle/es6", false, "umd", "umd"), rollupConfigFactory("build/es6/mod", "bundle/es6", true, "umd", "umd") -]; +].map((config) => Object.assign(config, { onwarn: failOnCircularImport }));