diff --git a/.github/workflows/check-e2e.yml b/.github/workflows/check-e2e.yml new file mode 100644 index 000000000..3425ed187 --- /dev/null +++ b/.github/workflows/check-e2e.yml @@ -0,0 +1,49 @@ +name: check-e2e +on: push + +jobs: + check-e2e: + strategy: + matrix: + browser: [webkit] + timeout-minutes: 30 + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Install dependencies + run: npm ci + + - name: Build test-http-api + run: npm run build:http + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-dep-cache + with: + path: ~/Library/Caches/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browsers + if: steps.playwright-dep-cache.outputs.cache-hit != 'true' + run: npx playwright install webkit + + - name: Install Radicle binaries + run: | + mkdir -p tests/artifacts; + ./scripts/install-binaries; + + - name: Run Playwright tests + run: npm run test:e2e -- --project ${{ matrix.browser }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-artifacts-${{ runner.os }} + retention-days: 30 + if-no-files-found: "ignore" + path: | + tests/artifacts/**/* diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1bcf05a82..9501c611d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,8 +23,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - with: - workspaces: crates/radicle-tauri -> target - uses: awalsh128/cache-apt-pkgs-action@latest with: packages: libgtk-3-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev diff --git a/.gitignore b/.gitignore index c2e63eb8f..07a2a7eac 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ public/twemoji/*.svg # Mac OS .DS_Store + +# Integration Tests +tests/tmp/**/* +tests/artifacts/**/* diff --git a/package-lock.json b/package-lock.json index aec455899..fd56874cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@playwright/test": "^1.48.2", "@radicle/gray-matter": "4.1.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tauri-apps/cli": "^2.0.3", @@ -24,14 +25,18 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.12", "@types/node": "^20.9.0", + "@types/wait-on": "^5.3.4", "@wooorm/starry-night": "^3.5.0", "baconjs": "^3.0.19", "bs58": "^6.0.0", "buffer": "^6.0.3", + "chalk": "^5.3.0", "dompurify": "^3.1.7", "eslint": "^9.13.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.45.1", + "execa": "^9.5.0", + "get-port": "^7.1.0", "hast-util-to-dom": "^4.0.0", "lodash": "^4.17.21", "marked": "^14.1.3", @@ -48,7 +53,8 @@ "twemoji": "^14.0.2", "typescript": "^5.6.3", "typescript-eslint": "^8.10.0", - "vite": "^5.4.9" + "vite": "^5.4.9", + "wait-on": "^8.0.1" }, "engines": { "node": "20.9.0" @@ -547,6 +553,21 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", @@ -678,6 +699,21 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.2.tgz", + "integrity": "sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==", + "dev": true, + "dependencies": { + "playwright": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radicle/gray-matter": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@radicle/gray-matter/-/gray-matter-4.1.0.tgz", @@ -901,6 +937,45 @@ "win32" ] }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.0.tgz", @@ -1235,6 +1310,15 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "dev": true }, + "node_modules/@types/wait-on": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@types/wait-on/-/wait-on-5.3.4.tgz", + "integrity": "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz", @@ -1545,6 +1629,23 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1657,16 +1758,12 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" @@ -1705,6 +1802,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -1779,6 +1888,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dompurify": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", @@ -2056,6 +2174,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/esm-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", @@ -2131,6 +2265,32 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.0.tgz", + "integrity": "sha512-t7vvYt+oKnMbF3O+S5+HkylsPrsUatwJSe4Cv+4017R0MCySjECxnVJ2eyDXVD/Xpj5H29YzyYn6eEpugG7GJA==", + "dev": true, + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -2212,6 +2372,21 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2271,6 +2446,40 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -2308,6 +2517,34 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2368,6 +2605,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/human-signals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", + "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2471,6 +2717,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-reference": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", @@ -2480,12 +2738,49 @@ "@types/estree": "*" } }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2741,6 +3036,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2753,6 +3069,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2792,6 +3117,34 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2851,6 +3204,18 @@ "node": ">=6" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2889,6 +3254,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz", + "integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.48.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", @@ -3035,6 +3444,21 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/pretty-ms": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.1.0.tgz", + "integrity": "sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==", + "dev": true, + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/property-information": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", @@ -3045,6 +3469,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3164,6 +3594,15 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -3222,6 +3661,18 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3240,6 +3691,18 @@ "node": ">=0.10.0" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3497,6 +3960,18 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -3606,6 +4081,25 @@ "integrity": "sha512-lxKSVp2DkFOx9RDAvpiYUrB9/KT1fAfi1aE8CBGstP8N7rLF+Seifj8kDA198X0mYj1CjQUC+81+nQf8CO0nVA==", "dev": true }, + "node_modules/wait-on": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.1.tgz", + "integrity": "sha512-1wWQOyR2LVVtaqrcIL2+OM+x7bkpmzVROa0Nf6FryXkS+er5Sa1kzFGjzZRqLnHa3n1rACFLeTwUqE1ETL9Mig==", + "dev": true, + "dependencies": { + "axios": "^1.7.7", + "joi": "^17.13.3", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.1" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -3661,6 +4155,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zimmerframe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", diff --git a/package.json b/package.json index 9ea83e55d..5e548e3e3 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,13 @@ "start": "vite", "start:http": "cargo run --manifest-path ./crates/test-http-api/Cargo.toml", "build": "vite build && scripts/copy-katex-assets && scripts/install-twemoji-assets", + "build:http": "cargo build --manifest-path ./crates/test-http-api/Cargo.toml", "postinstall": "scripts/copy-katex-assets && scripts/install-twemoji-assets", "preview": "vite preview", "check": "scripts/check-js && scripts/check-rs", "check-js": "scripts/check-js", "check-rs": "scripts/check-rs", + "test:e2e": "TZ='UTC' playwright test", "format": "npx prettier '**/*.@(ts|js|svelte|json|css|html|yml)' --write", "generate-types": "cargo test --manifest-path ./crates/radicle-types/Cargo.toml && npx prettier ./crates/radicle-types/bindings --write", "tauri": "npx tauri" @@ -29,6 +31,7 @@ }, "devDependencies": { "@eslint/js": "^9.13.0", + "@playwright/test": "^1.48.2", "@radicle/gray-matter": "4.1.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", "@tauri-apps/cli": "^2.0.3", @@ -36,14 +39,18 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.12", "@types/node": "^20.9.0", + "@types/wait-on": "^5.3.4", "@wooorm/starry-night": "^3.5.0", "baconjs": "^3.0.19", "bs58": "^6.0.0", "buffer": "^6.0.3", + "chalk": "^5.3.0", "dompurify": "^3.1.7", "eslint": "^9.13.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.45.1", + "execa": "^9.5.0", + "get-port": "^7.1.0", "hast-util-to-dom": "^4.0.0", "lodash": "^4.17.21", "marked": "^14.1.3", @@ -60,6 +67,7 @@ "twemoji": "^14.0.2", "typescript": "^5.6.3", "typescript-eslint": "^8.10.0", - "vite": "^5.4.9" + "vite": "^5.4.9", + "wait-on": "^8.0.1" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..4f3d80b45 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,41 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; +import { devices } from "@playwright/test"; + +const config: PlaywrightTestConfig = { + outputDir: "./tests/artifacts", + testDir: "./tests/e2e", + globalSetup: "./tests/support/globalSetup.ts", + timeout: 30_000, + expect: { + timeout: 8000, + }, + fullyParallel: true, + workers: process.env.CI ? 1 : undefined, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: "list", + use: { + colorScheme: "dark", + actionTimeout: 5000, + baseURL: "http://localhost:3001", + trace: "retain-on-failure", + }, + + projects: [ + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + }, + }, + ], + + webServer: [ + { + command: "npm run start -- --strictPort --port 3001", + port: 3001, + }, + ], +}; + +export default config; diff --git a/scripts/install-binaries b/scripts/install-binaries new file mode 100755 index 000000000..2344014d9 --- /dev/null +++ b/scripts/install-binaries @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") +RELEASE=$(cat "$REPO_ROOT/tests/support/heartwood-release") +BINARY_PATH=$REPO_ROOT/tests/tmp/bin +OS=$(uname) + +install() { + if test -d "$BINARY_PATH/$1/$2"; then + echo โœ… "Folder $BINARY_PATH/$1/$2 exists already skipping download." + else + mkdir -p "$BINARY_PATH/$1/$2" + case "$OS" in + Darwin) + ARCH="aarch64-apple-darwin" + ;; + Linux) + ARCH="x86_64-unknown-linux-musl" + ;; + *) + echo "There are no precompiled binaries for your OS: $OS, compile $1 manually and make sure it's in PATH." && exit 1 + ;; + esac + case "$1" in + heartwood) + FETCH_URL="https://files.radicle.xyz/releases/$2/radicle-$ARCH.tar.xz" + FILENAME="radicle-$2-$ARCH" + ;; + *) + echo "No precompiled binary found with the name $1." && exit 1 + ;; + esac + + echo Downloading "$1" v"$2" from "$FETCH_URL into /tests/tmp/bin/$1/$2" + curl --fail -s "$FETCH_URL" | tar -xJ --strip-components=2 -C "$BINARY_PATH/$1/$2" "$FILENAME/bin/" || (echo "Download failed" && exit 1) + fi +} + +show_usage() { + echo + echo "Installs binaries required for running e2e test suite." + echo + echo "USAGE:" + echo " install-binaries [-h]" + echo + echo "OPTIONS:" + echo " -h --help Print this Help." + echo +} + +while [ $# -ne 0 ]; do + case "$1" in + --help | -h) + show_usage + exit + ;; + esac +done + +install "heartwood" "$RELEASE" + +echo diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index c99c45431..310517d87 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -8,7 +8,7 @@ export async function invoke( if (window.__TAURI_INTERNALS__) { return tauri.invoke(cmd, args, options); } else { - return fetch(`http://127.0.0.1:8080/${cmd}`, { + return fetch(`http://127.0.0.1:8081/${cmd}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(args), diff --git a/src/lib/sleep.ts b/src/lib/sleep.ts new file mode 100644 index 000000000..7d994df1e --- /dev/null +++ b/src/lib/sleep.ts @@ -0,0 +1,5 @@ +export function sleep(timeMs: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, timeMs); + }); +} diff --git a/tests/e2e/repos.spec.ts b/tests/e2e/repos.spec.ts new file mode 100644 index 000000000..512f45c47 --- /dev/null +++ b/tests/e2e/repos.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from "@playwright/test"; + +test("navigate to repo issues", async ({ page }) => { + await page.goto("/repos"); + await page.getByRole("button", { name: "cobs" }).click(); + await page + .getByRole("button", { name: "This title has **markdown**" }) + .click(); + await expect( + page.getByText("This title has **markdown**").nth(1), + ).toBeVisible(); +}); diff --git a/tests/fixtures/repos/markdown.tar.bz2 b/tests/fixtures/repos/markdown.tar.bz2 new file mode 100644 index 000000000..fd43e620c Binary files /dev/null and b/tests/fixtures/repos/markdown.tar.bz2 differ diff --git a/tests/support/cobs/issue.ts b/tests/support/cobs/issue.ts new file mode 100644 index 000000000..956166586 --- /dev/null +++ b/tests/support/cobs/issue.ts @@ -0,0 +1,26 @@ +import type { RadiclePeer } from "@tests/support/peerManager.js"; +import type { Options } from "execa"; + +export async function create( + peer: RadiclePeer, + title: string, + description: string, + labels: string[], + options: Options, +): Promise { + const issueOptions: string[] = [ + "issue", + "open", + "--title", + title, + "--description", + description, + ...labels.map(label => ["--label", label]).flat(), + ]; + const { stdout } = await peer.rad(issueOptions, options); + const match = stdout.match(/Issue {3}([a-zA-Z0-9]*)/); + if (!match) { + throw new Error("Not able to parse issue id"); + } + return match[1]; +} diff --git a/tests/support/cobs/patch.ts b/tests/support/cobs/patch.ts new file mode 100644 index 000000000..449a30f77 --- /dev/null +++ b/tests/support/cobs/patch.ts @@ -0,0 +1,46 @@ +import type { RadiclePeer } from "@tests/support/peerManager.js"; +import type { Options } from "execa"; + +export async function create( + peer: RadiclePeer, + commitLines: string[], + branch: string, + changeFn: () => Promise, + messages: string[], + options: Options, +): Promise { + if (branch) { + await peer.git(["reset", "--hard"], options); + await peer.git(["switch", "main"], options); + await peer.git(["switch", "-c", branch], options); + } + await changeFn(); + await peer.git(["add", "."], options); + await peer.git( + ["commit"].concat(...commitLines.map(line => ["-m", line])), + options, + ); + const cmd = [ + "push", + ...messages.map(msg => ["-o", `patch.message=${msg}`]).flat(), + "rad", + "HEAD:refs/patches", + ]; + const { stderr } = await peer.git(cmd, options); + const match = stderr.match(/โœ“ Patch ([a-zA-Z0-9]*) opened/); + if (!match) { + throw new Error("Not able to parse patch id"); + } + return match[1]; +} + +export async function merge( + peer: RadiclePeer, + targetBranch: string, + featureBranch: string, + options: Options, +): Promise { + await peer.git(["switch", targetBranch], options); + await peer.git(["merge", featureBranch], options); + await peer.git(["push", "rad", targetBranch], options); +} diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts new file mode 100644 index 000000000..76ffb9673 --- /dev/null +++ b/tests/support/fixtures.ts @@ -0,0 +1,638 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { PeerManager, RadiclePeer } from "./peerManager.js"; +import type * as Stream from "node:stream"; + +import * as Fs from "node:fs/promises"; +import * as Path from "node:path"; +import { test as base, expect } from "@playwright/test"; +import { execa } from "execa"; + +import * as issue from "@tests/support/cobs/issue.js"; +import * as logLabel from "@tests/support/logPrefix.js"; +import * as patch from "@tests/support/cobs/patch.js"; +import { createOptions, supportDir, tmpDir } from "@tests/support/support.js"; +import { createPeerManager } from "@tests/support/peerManager.js"; +import { createRepo } from "@tests/support/repo.js"; +import { formatOid } from "@app/lib/utils.js"; + +export { expect }; + +const fixturesDir = Path.resolve(supportDir, "..", "./fixtures"); + +export const test = base.extend<{ + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + forAllTests: void; + stateDir: string; + peerManager: PeerManager; + peer: RadiclePeer; + outputLog: Stream.Writable; +}>({ + forAllTests: [ + async ({ outputLog, page }, use) => { + const browserLabel = logLabel.logPrefix("browser"); + page.on("console", msg => { + // Ignore common console logs that we don't care about. + if ( + msg.text().startsWith("[vite] connected.") || + msg.text().startsWith("[vite] connecting...") || + msg.text().startsWith("Not able to parse url") || + msg + .text() + .includes("Please make sure it wasn't preloaded for nothing.") + ) { + return; + } + log(msg.text(), browserLabel, outputLog); + }); + + if (!process.env.CONTINUE_ON_ERRORS) { + page.on("pageerror", msg => { + expect( + false, + `Test failed because there was a console error in the app: ${msg}`, + ).toBeTruthy(); + }); + } + + const playwrightLabel = logLabel.logPrefix("playwright"); + + function isLocalhost(url: URL) { + return url.hostname === "localhost" || url.hostname === "127.0.0.1"; + } + + await page.route( + url => !isLocalhost(url), + route => { + log( + `Aborted remote request: ${route.request().url()}`, + playwrightLabel, + outputLog, + ); + return route.abort(); + }, + ); + + await page.route( + url => + url.href.startsWith("https://www.gravatar.com/avatar/") || + (url.href.endsWith(".png") && !isLocalhost(url)), + route => { + return route.fulfill({ + status: 200, + path: "./public/radicle.svg", + }); + }, + ); + + await use(); + }, + { scope: "test", auto: true }, + ], + + outputLog: async ({ stateDir }, use) => { + const logFile = await Fs.open(Path.join(stateDir, "test.log"), "a"); + await use(logFile.createWriteStream()); + await logFile.close(); + }, + + peerManager: async ({ stateDir, outputLog }, use) => { + const peerManager = await createPeerManager({ + dataDir: Path.resolve(Path.join(stateDir, "peers")), + outputLog, + }); + await use(peerManager); + await peerManager.shutdown(); + }, + + peer: async ({ peerManager }, use) => { + const peer = await peerManager.createPeer({ + name: "httpd", + gitOptions: gitOptions["bob"], + }); + + await peer.startNode(); + await peer.startHttpd(); + + await use(peer); + }, + + // eslint-disable-next-line no-empty-pattern + stateDir: async ({}, use, testInfo) => { + const stateDir = testInfo.outputDir; + await Fs.rm(stateDir, { recursive: true, force: true }); + await Fs.mkdir(stateDir, { recursive: true }); + + await use(stateDir); + if ( + process.env.CI && + (testInfo.status === "passed" || testInfo.status === "skipped") + ) { + await Fs.rm(stateDir, { recursive: true }); + } + }, +}); + +function log(text: string, label: string, outputLog: Stream.Writable) { + const output = text + .split("\n") + .map(line => `${label}${line}`) + .join("\n"); + + outputLog.write(`${output}\n`); + if (!process.env.CI) { + console.log(output); + } +} + +export async function createCobsFixture( + peerManager: PeerManager, + peer: RadiclePeer, +) { + await peer.rad(["follow", peer.nodeId, "--alias", "palm"]); + await Fs.mkdir(Path.join(tmpDir, "repos", "cobs"), { recursive: true }); + const { repoFolder, rid, defaultBranch } = await createRepo(peer, { + name: "cobs", + }); + const eve = await peerManager.createPeer({ + name: "eve", + gitOptions: gitOptions["eve"], + }); + await eve.startNode({ + node: { ...defaultConfig.node, connect: [peer.address], alias: "eve" }, + }); + await eve.rad(["clone", rid], { cwd: eve.checkoutPath }); + + const issueOne = await issue.create( + peer, + "This `title` has **markdown**", + "This is a description\nWith some multiline text.", + ["bug", "feature-request"], + { cwd: repoFolder }, + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "๐Ÿ‘", "--to", issueOne], + { + cwd: repoFolder, + }, + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "๐ŸŽ‰", "--to", issueOne], + { + cwd: repoFolder, + }, + ); + await peer.rad( + ["issue", "assign", issueOne, "--add", `did:key:${peer.nodeId}`], + createOptions(repoFolder, 1), + ); + const { stdout: commentIssueOne } = await peer.rad( + [ + "issue", + "comment", + issueOne, + "--message", + "This is a multiline comment\n\nWith some more text.", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 2), + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "๐Ÿ™", "--to", commentIssueOne], + { + cwd: repoFolder, + }, + ); + const { stdout: replyIssueOne } = await peer.rad( + [ + "issue", + "comment", + issueOne, + "--message", + "This is a reply, to a first comment.", + "--reply-to", + commentIssueOne, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 3), + ); + await peer.rad( + ["issue", "react", issueOne, "--emoji", "๐Ÿš€", "--to", replyIssueOne], + { + cwd: repoFolder, + }, + ); + await peer.rad( + [ + "issue", + "comment", + issueOne, + "--message", + "A root level comment after a reply, for margins sake.", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 4), + ); + + const issueTwo = await issue.create( + peer, + "A closed issue", + "This issue has been closed\n\nsource: [link](https://radicle.xyz)", + [], + { cwd: repoFolder }, + ); + await peer.rad( + ["issue", "state", issueTwo, "--closed"], + createOptions(repoFolder, 1), + ); + + const issueThree = await issue.create( + peer, + "A solved issue", + "This issue has been solved\n\n```js\nconsole.log('hello world')\nconsole.log(\"\")\n```", + [], + { cwd: repoFolder }, + ); + await peer.rad( + ["issue", "state", issueThree, "--solved"], + createOptions(repoFolder, 1), + ); + + const patchOne = await patch.create( + peer, + ["Add README", "This commit adds more information to the README"], + "feature/add-readme", + () => Fs.writeFile(Path.join(repoFolder, "README.md"), "# Cobs Repo"), + ["Let's add a README", "This repo needed a README"], + { cwd: repoFolder }, + ); + const { stdout: commentPatchOne } = await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "I'll review the patch", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 1), + ); + await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Thanks for that!", + "--reply-to", + commentPatchOne, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 2), + ); + await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Yeah no problem!", + "--reply-to", + commentPatchOne, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 3), + ); + const { stdout: commentTwo } = await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Looking good so far", + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 4), + ); + await peer.rad( + [ + "patch", + "comment", + patchOne, + "--message", + "Thanks again!", + "--reply-to", + commentTwo, + "--quiet", + "--no-announce", + ], + createOptions(repoFolder, 5), + ); + await peer.rad( + ["patch", "review", patchOne, "-m", "LGTM", "--accept"], + createOptions(repoFolder, 6), + ); + await patch.merge( + peer, + defaultBranch, + "feature/add-readme", + createOptions(repoFolder, 7), + ); + + const patchTwo = await patch.create( + peer, + ["Add subtitle to README"], + "feature/add-more-text", + () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Subtitle"), + [], + { cwd: repoFolder }, + ); + await peer.rad( + [ + "patch", + "review", + patchTwo, + "-m", + "Not the README we are looking for", + "--reject", + ], + createOptions(repoFolder, 1), + ); + + const patchThree = await patch.create( + peer, + [ + "Rewrite subtitle to README", + "This was really necessary", + "Blazingly fast", + ], + "feature/better-subtitle", + () => Fs.appendFile(Path.join(repoFolder, "README.md"), "\n\n## Better?"), + [ + "Taking another stab at the README", + "This is a big improvement over the last one", + "Hopefully **this** is the last time", + ], + { cwd: repoFolder }, + ); + await peer.rad( + ["patch", "label", patchThree, "--add", "documentation"], + createOptions(repoFolder, 1), + ); + await eve.rad( + ["patch", "review", patchThree, "-m", "This looks better"], + createOptions(repoFolder, 2), + ); + await Fs.appendFile( + Path.join(repoFolder, "README.md"), + "\n\nHad to push a new revision", + ); + await peer.git(["add", "."], { cwd: repoFolder }); + await peer.git(["commit", "-m", "Add more text"], { cwd: repoFolder }); + await peer.git( + [ + "push", + "-o", + "patch.message=Most of the missing README text was caused by the git-daemon not having a writers block. It seems like using an RNG was not a good enough solution.", + "-o", + "patch.message=After this change, the README seem to be written correctly", + "rad", + "feature/better-subtitle", + ], + createOptions(repoFolder, 3), + ); + await peer.rad( + [ + "patch", + "review", + patchThree, + "-m", + "No this doesn't look better", + "--reject", + ], + createOptions(repoFolder, 2), + ); + + const patchFour = await patch.create( + peer, + ["This patch is going to be archived"], + "feature/archived", + () => Fs.writeFile(Path.join(repoFolder, "CONTRIBUTING.md"), "# Archived"), + [], + { cwd: repoFolder }, + ); + await peer.rad( + [ + "patch", + "review", + patchFour, + "-m", + "No review due to patch being archived.", + ], + createOptions(repoFolder, 1), + ); + await peer.rad(["patch", "archive", patchFour], createOptions(repoFolder, 2)); + + const patchFive = await patch.create( + peer, + ["This patch is going to be reverted to draft"], + "feature/draft", + () => Fs.writeFile(Path.join(repoFolder, "LICENSE"), "Draft"), + [], + { cwd: repoFolder }, + ); + await peer.rad( + ["patch", "ready", patchFive, "--undo"], + createOptions(repoFolder, 1), + ); +} + +export async function createMarkdownFixture(peer: RadiclePeer) { + await Fs.mkdir(Path.join(tmpDir, "repos", "markdown"), { recursive: true }); + await execa("tar", [ + "-xf", + Path.join(fixturesDir, "repos", "markdown.tar.bz2"), + "-C", + Path.join(tmpDir, "repos", "markdown"), + ]); + const { repoFolder } = await createRepo(peer, { name: "markdown" }); + await Fs.cp(Path.join(tmpDir, "repos", "markdown"), repoFolder, { + recursive: true, + }); + + await peer.git(["add", "."], { cwd: repoFolder }); + const commitMessage = `Add Markdown cheat sheet + + Borrowed from [Adam Pritchard][ap]. + No modifications were made. + + [ap]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet`; + await peer.git(["commit", "-m", commitMessage], { + cwd: repoFolder, + }); + await peer.git(["push", "rad"], { cwd: repoFolder }); + await issue.create( + peer, + "This `title` has **markdown**", + 'This is a description\n\nWith some multiline text.\n\n```\n23-11-06 10:19 โžœ radicle-jetbrains-plugin git:(main) rad id update --title "Godify jchrist" --description "where jchrist ascends to a god of this project" --delegate did:key:z6MkpaATbhkGbSMysNomYTFVvKG5bnNKYZ2cCamfoHzX9SnL --threshold 1\n\nโœ“ Identity revision 029837dde8f5c49704e50a19cd709473ac66a456 created\n```', + ["bug", "feature-request"], + { cwd: repoFolder }, + ); +} + +export const aliceMainHead = "7babd25a74eb3752ec24672b5edf0e7ecb4daf24"; +export const aliceMainCommitMessage = + "Verify that crate::DoubleColon::should_work()"; +export const aliceMainCommitCount = 8; +export const aliceRemote = + "did:key:z6MkqGC3nWZhYieEVTVDKW5v588CiGfsDSmRVG9ZwwWTvLSK"; +export const shortAliceHead = formatOid(aliceMainHead); +export const bobRemote = + "did:key:z6Mkg49NtQR2LyYRDCQFK4w1VVHqhypZSSRo7HsyuN7SV7v5"; +export const bobHead = "82f570ec909e77c7e1bb764f1429b1e01b1b4a90"; +export const bobMainCommitCount = 9; +export const shortBobHead = formatOid(bobHead); +export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe"; +export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg"; +export const shortNodeRemote = "z6MktUโ€ฆ1xB22S"; +export const defaultHttpdPort = 8081; +export const gitOptions = { + alice: { + GIT_AUTHOR_NAME: "Alice Liddell", + GIT_AUTHOR_EMAIL: "alice@radicle.xyz", + GIT_AUTHOR_DATE: "1727621093", + GIT_COMMITTER_NAME: "Alice Liddell", + GIT_COMMITTER_EMAIL: "alice@radicle.xyz", + GIT_COMMITTER_DATE: "1727621093", + }, + bob: { + GIT_AUTHOR_NAME: "Bob Belcher", + GIT_AUTHOR_EMAIL: "bob@radicle.xyz", + GIT_AUTHOR_DATE: "1727621093", + GIT_COMMITTER_NAME: "Bob Belcher", + GIT_COMMITTER_EMAIL: "bob@radicle.xyz", + GIT_COMMITTER_DATE: "1730220293", + }, + + eve: { + GIT_AUTHOR_NAME: "Eve Johnson", + GIT_AUTHOR_EMAIL: "eve@radicle.xyz", + GIT_AUTHOR_DATE: "1727621093", + GIT_COMMITTER_NAME: "Eve Johnson", + GIT_COMMITTER_EMAIL: "eve@radicle.xyz", + GIT_COMMITTER_DATE: "1730220293", + }, +}; +export const defaultConfig: Config = { + publicExplorer: "https://app.radicle.xyz/nodes/$host/$rid$path", + preferredSeeds: [], + web: { + pinned: { + repositories: [], + }, + }, + cli: { + hints: true, + }, + node: { + alias: "alice", + listen: [], + peers: { + type: "dynamic", + }, + connect: [], + externalAddresses: [], + network: "main", + log: "INFO", + relay: "auto", + limits: { + routingMaxSize: 1000, + routingMaxAge: 604800, + gossipMaxAge: 1209600, + fetchConcurrency: 1, + maxOpenFiles: 4096, + rate: { + inbound: { + fillRate: 5.0, + capacity: 1024, + }, + outbound: { + fillRate: 10.0, + capacity: 2048, + }, + }, + connection: { + inbound: 128, + outbound: 16, + }, + }, + workers: 8, + seedingPolicy: { + default: "block", + }, + }, +}; + +export type Config = { + publicExplorer: string; + preferredSeeds: string[]; + cli: { hints: boolean }; + web: { + pinned: { + repositories: string[]; + }; + imageUrl?: string; + name?: string; + description?: string; + }; + node: NodeConfig; +}; + +export type NodeConfig = { + alias: string; + peers: { type: "static" } | { type: "dynamic" }; + listen: string[]; + connect: string[]; + externalAddresses: string[]; + proxy?: string; + onion?: { mode: "proxy"; address: string } | { mode: "forward" }; + log: "ERROR" | "WARN" | "INFO" | "DEBUG" | "TRACE"; + network: "main" | "test"; + relay: "always" | "never" | "auto"; + limits: { + routingMaxSize: number; + routingMaxAge: number; + fetchConcurrency: number; + gossipMaxAge: number; + maxOpenFiles: number; + rate: { + inbound: { + fillRate: number; + capacity: number; + }; + outbound: { + fillRate: number; + capacity: number; + }; + }; + connection: { + inbound: number; + outbound: number; + }; + }; + workers: number; + seedingPolicy: + | { + default: "block"; + } + | { + default: "allow"; + scope: "followed" | "all"; + }; +}; diff --git a/tests/support/globalSetup.ts b/tests/support/globalSetup.ts new file mode 100644 index 000000000..15e03458c --- /dev/null +++ b/tests/support/globalSetup.ts @@ -0,0 +1,95 @@ +import * as Fs from "node:fs"; +import * as Path from "node:path"; +import { + assertBinariesInstalled, + heartwoodRelease, + removeWorkspace, + tmpDir, +} from "@tests/support/support.js"; +import { + defaultConfig, + createCobsFixture, + createMarkdownFixture, + defaultHttpdPort, + gitOptions, +} from "@tests/support/fixtures.js"; +import { createPeerManager } from "@tests/support/peerManager.js"; + +const heartwoodBinaryPath = Path.join( + tmpDir, + "bin", + "heartwood", + heartwoodRelease, +); + +process.env.PATH = [heartwoodBinaryPath, process.env.PATH].join(Path.delimiter); + +export default async function globalSetup(): Promise<() => void> { + try { + await assertBinariesInstalled("rad", heartwoodRelease, heartwoodBinaryPath); + } catch (error) { + console.error(error); + console.log(""); + console.log("To download the required test binaries, run:"); + console.log(" ๐Ÿ‘‰ ./scripts/install-binaries"); + console.log(""); + process.exit(1); + } + + if (!process.env.SKIP_FIXTURE_CREATION) { + console.log( + "Recreating static fixtures. Set SKIP_FIXTURE_CREATION to skip this", + ); + await removeWorkspace(); + } + + const peerManager = await createPeerManager({ + dataDir: Path.resolve(tmpDir, "peers"), + outputLog: Fs.createWriteStream( + Path.resolve(tmpDir, "globalPeerManager.log"), + ) + // Workaround for fixing MaxListenersExceededWarning. + // Since every prefixOutput stream adds stream listeners that don't autoClose. + // TODO: We still seem to have some descriptors left open when running vitest, which we should handle. + .setMaxListeners(16), + }); + + const palm = await peerManager.createPeer({ + name: "palm", + gitOptions: gitOptions["alice"], + }); + + if (!process.env.SKIP_FIXTURE_CREATION) { + await palm.startNode({ + node: { + ...defaultConfig.node, + seedingPolicy: { default: "allow", scope: "all" }, + alias: "palm", + }, + }); + await palm.startHttpd(defaultHttpdPort); + + try { + console.log("Creating markdown fixture"); + await createMarkdownFixture(palm); + console.log("Creating cobs fixture"); + await createCobsFixture(peerManager, palm); + console.log("All fixtures created"); + } catch (error) { + console.log(""); + console.log("Not able to create the required fixtures."); + console.log("Make sure you are not using binaries compiled for release."); + console.log(""); + console.log(error); + console.log(""); + process.exit(1); + } + await palm.stopNode(); + } else { + await palm.startHttpd(defaultHttpdPort); + } + + return async () => { + await peerManager.shutdown(); + }; +} diff --git a/tests/support/heartwood-release b/tests/support/heartwood-release new file mode 100644 index 000000000..afaf360d3 --- /dev/null +++ b/tests/support/heartwood-release @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/tests/support/logPrefix.ts b/tests/support/logPrefix.ts new file mode 100644 index 000000000..a517618f8 --- /dev/null +++ b/tests/support/logPrefix.ts @@ -0,0 +1,41 @@ +import type { ColorName } from "chalk"; + +import chalk from "chalk"; + +const PADDING_WIDTH = 12; + +// The order here is important, we want successive prefixes to have +// high contrast. +const availableColors: ColorName[] = [ + "blue", + "yellowBright", + "greenBright", + "gray", + "green", + "blueBright", + "redBright", + "white", + "yellow", + "red", + "magenta", + "cyan", +]; + +let nextColorIndex = 0; + +const assignedColors: Record = {}; + +export function logPrefix(label: string): string { + if (assignedColors[label] === undefined) { + const color = availableColors[nextColorIndex]; + nextColorIndex = (nextColorIndex + 1) % availableColors.length; + assignedColors[label] = color; + } + + // We reset colors at the beginning of each line to avoid styles from previous + // lines messing up prefix colors. This is noticable in rust stack traces + // where the `in` and `with` keywords have a white background color. + return chalk.reset[assignedColors[label]]( + `${label.padEnd(PADDING_WIDTH)} | `, + ); +} diff --git a/tests/support/peerManager.ts b/tests/support/peerManager.ts new file mode 100644 index 000000000..0b354bc34 --- /dev/null +++ b/tests/support/peerManager.ts @@ -0,0 +1,430 @@ +import type * as Execa from "execa"; + +import * as Fs from "node:fs/promises"; +import * as Os from "node:os"; +import * as Path from "node:path"; +import * as Stream from "node:stream"; +import * as Util from "node:util"; +import * as readline from "node:readline/promises"; +import getPort from "get-port"; +import matches from "lodash/matches.js"; +import waitOn from "wait-on"; +import { defaultConfig, type Config } from "@tests/support/fixtures.js"; +import { execa } from "execa"; +import { logPrefix } from "@tests/support/logPrefix.js"; +import { randomTag } from "@tests/support/support.js"; +import { sleep } from "@app/lib/sleep.js"; + +export type RefsUpdate = + | { updated: { name: string; old: string; new: string } } + | { created: { name: string; oid: string } } + | { deleted: { name: string; oid: string } } + | { skipped: { name: string; oid: string } }; + +export type NodeEvent = + | { + type: "refsFetched"; + remote: string; + rid: string; + updated: RefsUpdate[]; + } + | { + type: "refsSynced"; + remote: string; + rid: string; + } + | { + type: "seedDiscovered"; + rid: string; + nid: string; + } + | { + type: "seedDropped"; + nid: string; + rid: string; + } + | { + type: "peerConnected"; + nid: string; + } + | { + type: "peerDisconnected"; + nid: string; + reason: string; + }; + +export interface RoutingEntry { + nid: string; + rid: string; +} + +interface PeerManagerParams { + dataPath: string; + radSeed: string; + // Name for easy identification. Used on file system and in logs. + name: string; + gitOptions?: Record; + outputLog: Stream.Writable; +} + +export interface PeerManager { + createPeer(params: { + name: string; + gitOptions?: Record; + }): Promise; + /** + * Kill all processes spawned by any of the peers + */ + shutdown(): Promise; +} + +export async function createPeerManager(createParams: { + dataDir: string; + outputLog?: Stream.Writable; +}): Promise { + let outputLog: Stream.Writable; + let outputLogFile: Fs.FileHandle; + if (createParams.outputLog) { + outputLog = createParams.outputLog; + } else { + outputLogFile = await Fs.open( + Path.join(createParams.dataDir, "peerManager.log"), + "a", + ); + outputLog = outputLogFile.createWriteStream(); + } + + const peers: RadiclePeer[] = []; + return { + async createPeer(params) { + const peer = await RadiclePeer.create({ + dataPath: createParams.dataDir, + name: params.name, + gitOptions: params.gitOptions, + radSeed: Array(64) + .fill((peers.length + 1).toString()) + .join(""), + outputLog, + }); + peers.push(peer); + + return peer; + }, + async shutdown() { + await Promise.all(peers.map(peer => peer.shutdown())); + }, + }; +} + +// Specialize the return type of `execa()` to guarantee that `stdout` and +// `stderr` are strings. +type SpawnResult = Execa.ResultPromise< + SpawnOptions & { + stdout: (line: unknown) => AsyncGenerator; + stderr: (line: unknown) => AsyncGenerator; + encoding: "utf8"; + } +>; + +type SpawnOptions = Omit< + Execa.Options, + "stdin" | "stdout" | "stderr" | "lines" | "encoding" +>; + +export interface BaseUrl { + hostname: string; + port: number; + scheme: string; +} + +export class RadiclePeer { + public checkoutPath: string; + public nodeId: string; + + #radSeed: string; + #socket: string; + #radHome: string; + #eventRecords: NodeEvent[] = []; + #outputLog: Stream.Writable; + #gitOptions?: Record; + #listenSocketAddr?: string; + #httpdBaseUrl?: BaseUrl; + #nodeProcess?: SpawnResult; + // Name for easy identification. Used on file system and in logs. + #name: string; + #childProcesses: SpawnResult[] = []; + + private constructor(props: { + checkoutPath: string; + nodeId: string; + radSeed: string; + socket: string; + gitOptions?: Record; + radHome: string; + logFile: Stream.Writable; + name: string; + }) { + this.checkoutPath = props.checkoutPath; + this.nodeId = props.nodeId; + this.#gitOptions = props.gitOptions; + this.#radSeed = props.radSeed; + this.#socket = props.socket; + this.#radHome = props.radHome; + this.#outputLog = props.logFile; + this.#name = props.name; + } + + public async waitForEvent(searchEvent: NodeEvent, timeoutInMs: number) { + const start = new Date().getTime(); + + while (true) { + if (this.#eventRecords.find(matches(searchEvent))) { + return; + } + if (new Date().getTime() - start > timeoutInMs) { + throw Error( + `Timeout waiting for event on node ${this.#name} ${Util.inspect( + searchEvent, + { depth: null }, + )}`, + ); + } + await sleep(100); + } + } + + public static async create({ + dataPath, + name, + gitOptions, + radSeed: node, + outputLog: logFile, + }: PeerManagerParams): Promise { + const checkoutPath = Path.join(dataPath, name, "copy"); + await Fs.mkdir(checkoutPath, { recursive: true }); + const radHome = Path.join(dataPath, name, "home"); + await Fs.mkdir(radHome, { recursive: true }); + + const socketDir = await Fs.mkdtemp( + Path.join(Os.tmpdir(), `radicle-${randomTag()}`), + ); + const socket = Path.join(socketDir, "control.sock"); + + /* eslint-disable @typescript-eslint/naming-convention */ + const env = { + ...gitOptions, + RAD_HOME: radHome, + RAD_PASSPHRASE: "asdf", + RAD_KEYGEN_SEED: node, + RAD_SOCKET: socket, + }; + /* eslint-enable @typescript-eslint/naming-convention */ + + await execa("rad", ["auth", "--alias", name], { env }); + const { stdout: nodeId } = await execa("rad", ["self", "--nid"], { env }); + + return new RadiclePeer({ + checkoutPath, + gitOptions, + radSeed: node, + socket, + nodeId, + radHome, + logFile, + name, + }); + } + + public async startHttpd(port?: number): Promise { + if (!port) { + port = await getPort(); + } + this.#httpdBaseUrl = { + hostname: "127.0.0.1", + port, + scheme: "http", + }; + void this.spawn("cargo", [ + "run", + "--manifest-path", + "./crates/test-http-api/Cargo.toml", + "--", + "--listen", + `${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`, + ]); + + await waitOn({ + resources: [ + `tcp:${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`, + ], + timeout: 20_000, + }); + } + + public async startNode(config: Partial = defaultConfig) { + const listenPort = await getPort(); + this.#listenSocketAddr = `0.0.0.0:${listenPort}`; + + await updateConfig(this.#radHome, config); + + this.#nodeProcess = this.spawn("radicle-node", [ + "--listen", + this.#listenSocketAddr, + ]); + + await waitOn({ + resources: [`socket:${this.#socket}`], + timeout: 2000, + }); + + const { stdout } = this.rad(["node", "events"], { + cwd: this.#radHome, + }); + + if (!stdout) { + throw new Error("Could not get stdout to track events"); + } + + readline + .createInterface({ + input: stdout, + terminal: false, + }) + .on("line", line => { + let event; + try { + event = JSON.parse(line); + } catch { + console.log("Error parsing event", line); + return; + } + + this.#eventRecords.push(event); + for (const line of Util.inspect(event, { depth: null }).split("\n")) { + this.#outputLog.write( + `${logPrefix(`${this.#name} node events`)} ${line}\n`, + ); + } + }); + } + + public async stopNode() { + // Donโ€™t leak unhandled rejections when forcefully killing the process + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.#nodeProcess?.catch(() => {}); + this.#nodeProcess?.kill("SIGTERM"); + + await waitOn({ + resources: [`socket:${this.#socket}`], + reverse: true, + timeout: 2000, + }); + } + + /** + * Kill all child processes created with `spawn()`, including the node and + * httpd processes. + */ + public async shutdown() { + // We donโ€™t care about proper cleanup. We just want to make sure that no + // processes are running anymore. + this.#childProcesses.forEach(p => { + // Donโ€™t leak unhandled rejections when forcefully killing the process + // eslint-disable-next-line @typescript-eslint/no-empty-function + p.catch(() => {}); + p.kill("SIGKILL"); + }); + } + + public get address(): string { + if (!this.#listenSocketAddr) { + throw new Error("Remote node has no listen addr yet"); + } + return `${this.nodeId}@${this.#listenSocketAddr}`; + } + + public uiUrl(): string { + if (!this.#httpdBaseUrl) { + throw new Error("No httpd service running"); + } + + return `/nodes/${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`; + } + + public ridUrl(rid: string): string { + return `/nodes/${this.httpdBaseUrl.hostname}:${this.httpdBaseUrl.port}/${rid}`; + } + + public get httpdBaseUrl(): BaseUrl { + if (!this.#httpdBaseUrl) { + throw new Error("No httpd service running"); + } + + return this.#httpdBaseUrl; + } + + public git(args: string[] = [], opts?: SpawnOptions): SpawnResult { + return this.spawn("git", args, { ...opts }); + } + + public rad(args: string[] = [], opts?: SpawnOptions): SpawnResult { + return this.spawn("rad", args, { ...opts }); + } + + public spawn( + cmd: string, + args: string[] = [], + opts?: SpawnOptions, + ): SpawnResult { + const prefix = logPrefix(`${this.#name} ${cmd}`); + const outputLog = this.#outputLog; + + function* logWithPrefix(line: unknown) { + if (typeof line === "string") { + outputLog.write(`${prefix} ${line}\n`, "utf8"); + } + yield line; + } + + /* eslint-disable @typescript-eslint/naming-convention */ + const childProcess = execa(cmd, args, { + ...opts, + env: { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + RAD_HOME: this.#radHome, + RAD_PASSPHRASE: "asdf", + RAD_LOCAL_TIME: "1671125284", + RAD_KEYGEN_SEED: this.#radSeed, + RAD_SOCKET: this.#socket, + ...opts?.env, + ...this.#gitOptions, + }, + encoding: "utf8", + stdout: logWithPrefix, + stderr: logWithPrefix, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + this.#childProcesses.push(childProcess); + + return childProcess; + } +} + +async function updateConfig(radHome: string, configParams: Partial) { + const configPath = Path.join(radHome, "config.json"); + const configFile = await Fs.readFile(configPath, "utf-8"); + const config = { + defaultConfig, + ...JSON.parse(configFile), + }; + config.preferredSeeds = []; + config.web = { ...config.web, ...configParams.web }; + config.node = { + ...config.node, + ...configParams.node, + network: "test", + }; + await Fs.writeFile(configPath, JSON.stringify(config), "utf-8"); +} diff --git a/tests/support/repo.ts b/tests/support/repo.ts new file mode 100644 index 000000000..d5b2227ab --- /dev/null +++ b/tests/support/repo.ts @@ -0,0 +1,66 @@ +import type { Page } from "@playwright/test"; +import type { RadiclePeer } from "@tests/support/peerManager"; + +import * as Path from "node:path"; + +export async function changeBranch(peer: string, branch: string, page: Page) { + await page.getByTitle("Change branch").click(); + const peerLocator = page.getByLabel("peer-item").filter({ hasText: peer }); + await peerLocator.getByTitle("Expand peer").click(); + await page.getByRole("button", { name: branch }).click(); +} + +// Create a repo using the rad CLI. +export async function createRepo( + peer: RadiclePeer, + { + name, + description = "", + defaultBranch = "main", + visibility = "public", + }: { + name: string; + description?: string; + defaultBranch?: string; + visibility?: "public" | "private"; + }, +): Promise<{ rid: string; repoFolder: string; defaultBranch: string }> { + const repoFolder = Path.join(peer.checkoutPath, name); + + await peer.git(["init", name, "--initial-branch", defaultBranch], { + cwd: peer.checkoutPath, + }); + await peer.git(["commit", "--allow-empty", "--message", "initial commit"], { + cwd: repoFolder, + }); + await peer.rad( + [ + "init", + "--name", + name, + "--default-branch", + defaultBranch, + "--description", + description, + `--${visibility}`, + ], + { + cwd: repoFolder, + }, + ); + + const { stdout: rid } = await peer.rad(["inspect"], { + cwd: repoFolder, + }); + + return { rid, repoFolder, defaultBranch }; +} + +export function extractPatchId(cmdOutput: { stderr: string }) { + const match = cmdOutput.stderr.match(/[0-9a-f]{40}/); + if (match) { + return match[0]; + } else { + throw new Error("Could not get patch id"); + } +} diff --git a/tests/support/router.ts b/tests/support/router.ts new file mode 100644 index 000000000..3d54c784c --- /dev/null +++ b/tests/support/router.ts @@ -0,0 +1,29 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@tests/support/fixtures.js"; + +// Reloads the current page and verifies that the URL stays correct +export const expectUrlPersistsReload = async (page: Page) => { + const url = page.url(); + await page.reload(); + await expect(page).toHaveURL(url); +}; + +// Navigates back, checks the URL and navigates forward back to the initial page +export const expectBackAndForwardNavigationWorks = async ( + beforeURL: string, + page: Page, +) => { + const currentURL = page.url(); + + await page.goBack(); + await page + .getByRole("progressbar", { name: "Page loading" }) + .waitFor({ state: "hidden" }); + await expect(page).toHaveURL(beforeURL); + await page.goForward(); + + await page + .getByRole("progressbar", { name: "Page loading" }) + .waitFor({ state: "hidden" }); + await expect(page).toHaveURL(currentURL); +}; diff --git a/tests/support/support.ts b/tests/support/support.ts new file mode 100644 index 000000000..9724cc8a1 --- /dev/null +++ b/tests/support/support.ts @@ -0,0 +1,60 @@ +import type { Options } from "execa"; + +import { execa } from "execa"; +import * as Crypto from "node:crypto"; +import { fileURLToPath } from "node:url"; +import * as Path from "node:path"; +import * as Fs from "node:fs/promises"; + +// Generate string of 12 random characters with 8 bits of entropy. +export function randomTag(): string { + return Crypto.randomBytes(8).toString("hex"); +} + +export function createOptions(repoFolder: string, days: number): Options { + return { + cwd: repoFolder, + // eslint-disable-next-line @typescript-eslint/naming-convention + env: { RAD_LOCAL_TIME: (1671211684 + days * 86400).toString() }, + }; +} + +const filename = fileURLToPath(import.meta.url); +export const supportDir = Path.dirname(filename); +export const tmpDir = Path.resolve(supportDir, "..", "./tmp"); +export const fixturesDir = Path.resolve(supportDir, "..", "./fixtures"); +const workspacePaths = [Path.join(tmpDir, "peers"), Path.join(tmpDir, "repos")]; + +export const heartwoodRelease = await Fs.readFile( + `${supportDir}/heartwood-release`, + "utf8", +); + +// Assert that binaries are installed and are the correct version. +export async function assertBinariesInstalled( + binary: string, + expectedVersion: string, + expectedPath: string, +): Promise { + const { stdout: which } = await execa("which", [binary]); + if (Path.dirname(which) !== expectedPath) { + throw new Error( + `${binary} path doesn't match used ${binary} binary: ${expectedPath} !== ${which}`, + ); + } + const { stdout: version } = await execa(binary, ["--version"]); + if (!version.includes(expectedVersion)) { + throw new Error( + `${binary} version ${version} does not satisfy ${expectedVersion}`, + ); + } +} + +export async function removeWorkspace(): Promise { + for (const path of workspacePaths) { + await Fs.rm(path, { + recursive: true, + force: true, + }); + } +} diff --git a/tsconfig.json b/tsconfig.json index 10e87ae1d..bf5568e06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@tsconfig/svelte/tsconfig.json", - "include": ["src", "./*.js", "./*.ts"], + "include": ["src", "tests", "./*.js", "./*.ts"], "exclude": ["node_modules/*", "isolation/*"], "compilerOptions": { "noEmit": true, @@ -21,7 +21,8 @@ "paths": { "@app/*": ["./src/*"], "@bindings/*": ["./crates/radicle-types/bindings/*"], - "@public/*": ["./public/*"] + "@public/*": ["./public/*"], + "@tests/*": ["./tests/*"] } } }