├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ ├── ecosystem-ci-from-pr.yml │ ├── ecosystem-ci-selected.yml │ └── ecosystem-ci.yml ├── .gitignore ├── .npmrc ├── CODEOWNERS ├── LICENSE ├── README.md ├── discord-webhook.ts ├── docs ├── github_app_id.png ├── github_app_private_key.png └── pr-comment-setup.md ├── ecosystem-ci.ts ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── tests ├── _selftest.ts ├── antoine-zanardi-portfolio.ts ├── bridge.ts ├── cli.ts ├── content.ts ├── devtools.ts ├── docus.ts ├── elk.ts ├── example-layers-monorepo.ts ├── examples.ts ├── fonts.ts ├── histoire.ts ├── i18n-module.ts ├── icon.ts ├── image.ts ├── module-builder.ts ├── nuxt-com.ts ├── og-image.ts ├── pinia.ts ├── sanity-module.ts ├── scripts.ts ├── sitemap.ts ├── starter.ts ├── storybook.ts ├── tailwindcss.ts ├── test-utils.ts ├── ui.ts ├── vite-pwa.ts └── werewolves-assistant.ts ├── tsconfig.json ├── types.d.ts └── utils.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = tab 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [package.json] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | # 7 GiB by default on GitHub, setting to 6 GiB 5 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources 6 | NODE_OPTIONS: --max-old-space-size=6144 7 | # configure corepack to be strict but not download newer versions or change anything 8 | COREPACK_DEFAULT_TO_LATEST: 0 9 | COREPACK_ENABLE_AUTO_PIN: 0 10 | COREPACK_ENABLE_STRICT: 1 11 | 12 | on: 13 | push: 14 | branches: 15 | - main 16 | pull_request: 17 | branches: 18 | - main 19 | 20 | jobs: 21 | ci: 22 | timeout-minutes: 10 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | - run: npm i -g --force corepack && corepack enable 30 | - run: pnpm --version 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | cache: pnpm 35 | cache-dependency-path: '**/pnpm-lock.yaml' 36 | - name: install 37 | run: pnpm install --frozen-lockfile --prefer-offline 38 | - name: lint 39 | run: pnpm run lint 40 | - name: typecheck 41 | run: pnpm run typecheck 42 | - name: audit 43 | if: (${{ success() }} || ${{ failure() }}) 44 | run: pnpm audit 45 | - name: test 46 | if: (${{ success() }} || ${{ failure() }}) 47 | run: pnpm test:self 48 | -------------------------------------------------------------------------------- /.github/workflows/ecosystem-ci-from-pr.yml: -------------------------------------------------------------------------------- 1 | # integration tests for nuxt ecosystem - run from pr comments 2 | name: nuxt-ecosystem-ci-from-pr 3 | 4 | env: 5 | # 7 GiB by default on GitHub, setting to 6 GiB 6 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources 7 | NODE_OPTIONS: --max-old-space-size=6144 8 | # configure corepack to be strict but not download newer versions or change anything 9 | COREPACK_DEFAULT_TO_LATEST: 0 10 | COREPACK_ENABLE_AUTO_PIN: 0 11 | COREPACK_ENABLE_STRICT: 1 12 | 13 | on: 14 | workflow_dispatch: 15 | inputs: 16 | prNumber: 17 | description: PR number (e.g. 9887) 18 | required: true 19 | type: string 20 | branchName: 21 | description: nuxt branch to use 22 | required: true 23 | type: string 24 | default: main 25 | nitro: 26 | description: the version of nitro to use 27 | type: choice 28 | options: 29 | - v3 nightly 30 | - v2 nightly 31 | - v2 latest 32 | default: v2 latest 33 | repo: 34 | description: nuxt repository to use 35 | required: true 36 | type: string 37 | default: nuxt/nuxt 38 | suite: 39 | description: test suite to run. runs all test suites when `-`. 40 | required: false 41 | type: choice 42 | options: 43 | - '-' 44 | - starter 45 | - content 46 | - ui 47 | - image 48 | - pinia 49 | - examples 50 | - example-layers-monorepo 51 | - bridge 52 | - nuxt-com 53 | - vite-pwa 54 | - docus 55 | - og-image 56 | - histoire 57 | - elk 58 | - devtools 59 | - fonts 60 | - scripts 61 | - icon 62 | - cli 63 | - test-utils 64 | - module-builder 65 | - sanity-module 66 | - tailwindcss 67 | - sitemap 68 | - i18n-module 69 | - werewolves-assistant 70 | - antoine-zanardi-portfolio 71 | - storybook 72 | jobs: 73 | init: 74 | runs-on: ubuntu-latest 75 | outputs: 76 | comment-id: ${{ steps.create-comment.outputs.result }} 77 | steps: 78 | - id: generate-token 79 | uses: tibdex/github-app-token@v1 80 | with: 81 | app_id: ${{ secrets.PR_GITHUB_APP_ID }} 82 | private_key: ${{ secrets.PR_GITHUB_APP_PRIVATE_KEY }} 83 | repository: '${{ github.repository_owner }}/nuxt' 84 | - id: create-comment 85 | uses: actions/github-script@v7 86 | with: 87 | github-token: ${{ steps.generate-token.outputs.token }} 88 | result-encoding: string 89 | script: | 90 | const url = `${context.serverUrl}//${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` 91 | const urlLink = `[Open](${url})` 92 | 93 | const { data: comment } = await github.rest.issues.createComment({ 94 | issue_number: context.payload.inputs.prNumber, 95 | owner: context.repo.owner, 96 | repo: 'nuxt', 97 | body: `⏳ Triggered ecosystem CI: ${urlLink}` 98 | }) 99 | return comment.id 100 | 101 | execute-selected-suite: 102 | timeout-minutes: 30 103 | runs-on: ubuntu-latest 104 | needs: init 105 | if: 'inputs.suite != ''-''' 106 | outputs: 107 | ref: ${{ steps.get-ref.outputs.ref }} 108 | steps: 109 | - uses: actions/checkout@v4 110 | - uses: actions/setup-node@v4 111 | with: 112 | node-version: 20 113 | - run: npm i -g --force corepack && corepack enable 114 | - run: pnpm --version 115 | - run: pnpm i --frozen-lockfile 116 | - run: >- 117 | pnpm tsx ecosystem-ci.ts 118 | --branch ${{ inputs.branchName }} 119 | --repo ${{ inputs.repo }} 120 | ${{ inputs.suite }} 121 | env: 122 | NUXT_UI_PRO_TOKEN: ${{ secrets.NUXT_UI_PRO_TOKEN }} 123 | NUXT_UI_PRO_LICENSE: ${{ secrets.NUXT_UI_PRO_TOKEN }} 124 | NITRO_VERSION: ${{ inputs.nitro }} 125 | - id: get-ref 126 | if: always() 127 | run: | 128 | ref=$(git log -1 --pretty=format:%H) 129 | echo "ref=$ref" >> $GITHUB_OUTPUT 130 | working-directory: workspace/nuxt 131 | 132 | execute-all: 133 | timeout-minutes: 30 134 | runs-on: ubuntu-latest 135 | needs: init 136 | if: 'inputs.suite == ''-''' 137 | outputs: 138 | ref: ${{ steps.get-ref.outputs.ref }} 139 | strategy: 140 | matrix: 141 | suite: 142 | - starter 143 | - content 144 | - ui 145 | - image 146 | - pinia 147 | - examples 148 | - example-layers-monorepo 149 | - bridge 150 | - nuxt-com 151 | - vite-pwa 152 | - docus 153 | - og-image 154 | - histoire 155 | - elk 156 | - devtools 157 | - fonts 158 | - scripts 159 | - icon 160 | - cli 161 | - test-utils 162 | - module-builder 163 | - sanity-module 164 | - tailwindcss 165 | - sitemap 166 | - i18n-module 167 | # - werewolves-assistant 168 | - antoine-zanardi-portfolio 169 | - storybook 170 | fail-fast: false 171 | steps: 172 | - uses: actions/checkout@v4 173 | - uses: actions/setup-node@v4 174 | with: 175 | node-version: 20 176 | - run: npm i -g --force corepack && corepack enable 177 | - run: pnpm --version 178 | - run: pnpm i --frozen-lockfile 179 | - run: >- 180 | pnpm tsx ecosystem-ci.ts 181 | --branch ${{ inputs.branchName }} 182 | --repo ${{ inputs.repo }} 183 | ${{ matrix.suite }} 184 | env: 185 | NUXT_UI_PRO_TOKEN: ${{ secrets.NUXT_UI_PRO_TOKEN }} 186 | NUXT_UI_PRO_LICENSE: ${{ secrets.NUXT_UI_PRO_TOKEN }} 187 | - id: get-ref 188 | if: always() 189 | run: | 190 | ref=$(git log -1 --pretty=format:%H) 191 | echo "ref=$ref" >> $GITHUB_OUTPUT 192 | working-directory: workspace/nuxt 193 | 194 | update-comment: 195 | runs-on: ubuntu-latest 196 | needs: [init, execute-selected-suite, execute-all] 197 | if: always() 198 | steps: 199 | - id: generate-token 200 | uses: tibdex/github-app-token@v1 201 | with: 202 | app_id: ${{ secrets.PR_GITHUB_APP_ID }} 203 | private_key: ${{ secrets.PR_GITHUB_APP_PRIVATE_KEY }} 204 | repository: '${{ github.repository_owner }}/nuxt' 205 | - uses: actions/github-script@v7 206 | with: 207 | github-token: ${{ steps.generate-token.outputs.token }} 208 | script: | 209 | const mainRepoName = 'nuxt' 210 | const ref = "${{ needs.execute-all.outputs.ref }}" || "${{ needs.execute-selected-suite.outputs.ref }}" 211 | const refLink = `[\`${ref.slice(0, 7)}\`](${context.serverUrl}/${context.repo.owner}/${mainRepoName}/pull/${context.payload.inputs.prNumber}/commits/${ref})` 212 | 213 | const { data: { jobs } } = await github.rest.actions.listJobsForWorkflowRun({ 214 | owner: context.repo.owner, 215 | repo: context.repo.repo, 216 | run_id: context.runId, 217 | per_page: 100 218 | }); 219 | 220 | const selectedSuite = context.payload.inputs.suite 221 | let results 222 | if (selectedSuite !== "-") { 223 | const { conclusion, html_url } = jobs.find(job => job.name === "execute-selected-suite") 224 | results = [{ suite: selectedSuite, conclusion, link: html_url }] 225 | } else { 226 | results = jobs 227 | .filter(job => job.name.startsWith('execute-all ')) 228 | .map(job => { 229 | const suite = job.name.replace(/^execute-all \(([^)]+)\)$/, "$1") 230 | return { suite, conclusion: job.conclusion, link: job.html_url } 231 | }) 232 | } 233 | 234 | const url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` 235 | const urlLink = `[Open](${url})` 236 | 237 | const conclusionEmoji = { 238 | success: ":white_check_mark:", 239 | failure: ":x:", 240 | cancelled: ":stop_button:" 241 | } 242 | 243 | // check for previous ecosystem-ci runs against the main branch 244 | 245 | // first, list workflow runs for ecosystem-ci.yml 246 | const { data: { workflow_runs } } = await github.rest.actions.listWorkflowRuns({ 247 | owner: context.repo.owner, 248 | repo: context.repo.repo, 249 | workflow_id: 'ecosystem-ci.yml' 250 | }); 251 | 252 | // for simplity, we only take the latest completed scheduled run 253 | // otherwise we would have to check the inputs for every maunally triggerred runs, which is an overkill 254 | const latestScheduledRun = workflow_runs.find(run => run.event === "schedule" && run.status === "completed") 255 | 256 | // get the jobs for the latest scheduled run 257 | const { data: { jobs: scheduledJobs } } = await github.rest.actions.listJobsForWorkflowRun({ 258 | owner: context.repo.owner, 259 | repo: context.repo.repo, 260 | run_id: latestScheduledRun.id 261 | }); 262 | const scheduledResults = scheduledJobs 263 | .filter(job => job.name.startsWith('test-ecosystem ')) 264 | .map(job => { 265 | const suite = job.name.replace(/^test-ecosystem \(([^)]+)\)$/, "$1") 266 | return { suite, conclusion: job.conclusion, link: job.html_url } 267 | }) 268 | 269 | const rows = [] 270 | const successfulSuitesWithoutChanges = [] 271 | results.forEach(current => { 272 | const latest = scheduledResults.find(s => s.suite === current.suite) || {} // in case a new suite is added after latest scheduled 273 | 274 | if (current.conclusion === "success" && latest.conclusion === "success") { 275 | successfulSuitesWithoutChanges.push(`[${current.suite}](${current.link})`) 276 | } 277 | else { 278 | const firstColumn = current.suite 279 | const secondColumn = `${conclusionEmoji[current.conclusion]} [${current.conclusion}](${current.link})` 280 | const thirdColumn = `${conclusionEmoji[latest.conclusion]} [${latest.conclusion}](${latest.link})` 281 | 282 | rows.push(`| ${firstColumn} | ${secondColumn} | ${thirdColumn} |`) 283 | } 284 | }) 285 | 286 | let body = ` 287 | 📝 Ran ecosystem CI on ${refLink}: ${urlLink} 288 | 289 | ` 290 | if (rows.length > 0) { 291 | body += `| suite | result | [latest scheduled](${latestScheduledRun.html_url}) | 292 | |-------|--------|----------------| 293 | ${rows.join("\n")} 294 | 295 | ${conclusionEmoji.success} ${successfulSuitesWithoutChanges.join(", ")} 296 | ` 297 | } else { 298 | body += `${conclusionEmoji.success} ${successfulSuitesWithoutChanges.join(", ")} 299 | ` 300 | } 301 | 302 | await github.rest.issues.createComment({ 303 | issue_number: context.payload.inputs.prNumber, 304 | owner: context.repo.owner, 305 | repo: mainRepoName, 306 | comment_id: ${{ needs.init.outputs.comment-id }}, 307 | body 308 | }) 309 | 310 | await github.rest.issues.deleteComment({ 311 | owner: context.repo.owner, 312 | repo: mainRepoName, 313 | comment_id: ${{ needs.init.outputs.comment-id }} 314 | }) 315 | -------------------------------------------------------------------------------- /.github/workflows/ecosystem-ci-selected.yml: -------------------------------------------------------------------------------- 1 | # integration tests for nuxt ecosystem - single run of selected testsuite 2 | name: nuxt-ecosystem-ci-selected 3 | 4 | env: 5 | # 7 GiB by default on GitHub, setting to 6 GiB 6 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources 7 | NODE_OPTIONS: --max-old-space-size=6144 8 | # configure corepack to be strict but not download newer versions or change anything 9 | COREPACK_DEFAULT_TO_LATEST: 0 10 | COREPACK_ENABLE_AUTO_PIN: 0 11 | COREPACK_ENABLE_STRICT: 1 12 | 13 | on: 14 | workflow_dispatch: 15 | inputs: 16 | refType: 17 | description: type of nuxt ref to use 18 | required: true 19 | type: choice 20 | options: 21 | - branch 22 | - tag 23 | - commit 24 | - release 25 | default: branch 26 | ref: 27 | description: nuxt ref to use 28 | required: true 29 | type: string 30 | default: main 31 | nitro: 32 | description: the version of nitro to use 33 | type: choice 34 | options: 35 | - v3 nightly 36 | - v2 nightly 37 | - v2 latest 38 | default: v2 latest 39 | repo: 40 | description: nuxt repository to use 41 | required: true 42 | type: string 43 | default: nuxt/nuxt 44 | suite: 45 | description: testsuite to run 46 | required: true 47 | type: choice 48 | options: 49 | - starter 50 | - content 51 | - ui 52 | - image 53 | - pinia 54 | - examples 55 | - example-layers-monorepo 56 | - bridge 57 | - nuxt-com 58 | - vite-pwa 59 | - docus 60 | - og-image 61 | - histoire 62 | - elk 63 | - devtools 64 | - fonts 65 | - scripts 66 | - icon 67 | - cli 68 | - test-utils 69 | - module-builder 70 | - sanity-module 71 | - tailwindcss 72 | - sitemap 73 | - i18n-module 74 | - werewolves-assistant 75 | - antoine-zanardi-portfolio 76 | - storybook 77 | jobs: 78 | execute-selected-suite: 79 | timeout-minutes: 30 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v4 83 | - uses: actions/setup-node@v4 84 | with: 85 | node-version: 20 86 | id: setup-node 87 | - run: npm i -g --force corepack && corepack enable 88 | - run: pnpm --version 89 | - run: pnpm i --frozen-lockfile 90 | - run: >- 91 | pnpm tsx ecosystem-ci.ts 92 | --${{ inputs.refType }} ${{ inputs.ref }} 93 | --repo ${{ inputs.repo }} 94 | ${{ inputs.suite }} 95 | id: ecosystem-ci-run 96 | env: 97 | NUXT_UI_PRO_TOKEN: ${{ secrets.NUXT_UI_PRO_TOKEN }} 98 | NUXT_UI_PRO_LICENSE: ${{ secrets.NUXT_UI_PRO_TOKEN }} 99 | NITRO_VERSION: ${{ inputs.nitro }} 100 | - if: always() 101 | run: pnpm tsx discord-webhook.ts 102 | env: 103 | WORKFLOW_NAME: ci-selected 104 | REF_TYPE: ${{ inputs.refType }} 105 | REF: ${{ inputs.ref }} 106 | REPO: ${{ inputs.repo }} 107 | SUITE: ${{ inputs.suite }} 108 | STATUS: ${{ job.status }} 109 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 110 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 111 | -------------------------------------------------------------------------------- /.github/workflows/ecosystem-ci.yml: -------------------------------------------------------------------------------- 1 | # integration tests for nuxt ecosystem projects - scheduled or manual run for all suites 2 | name: nuxt-ecosystem-ci 3 | 4 | env: 5 | # 7 GiB by default on GitHub, setting to 6 GiB 6 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources 7 | NODE_OPTIONS: --max-old-space-size=6144 8 | # configure corepack to be strict but not download newer versions or change anything 9 | COREPACK_DEFAULT_TO_LATEST: 0 10 | COREPACK_ENABLE_AUTO_PIN: 0 11 | COREPACK_ENABLE_STRICT: 1 12 | 13 | on: 14 | schedule: 15 | - cron: '0 5 * * 1,3,5' # monday,wednesday,friday 5AM 16 | workflow_dispatch: 17 | inputs: 18 | refType: 19 | description: type of ref 20 | required: true 21 | type: choice 22 | options: 23 | - branch 24 | - tag 25 | - commit 26 | - release 27 | default: branch 28 | ref: 29 | description: nuxt ref to use 30 | required: true 31 | type: string 32 | default: main 33 | nitro: 34 | description: the version of nitro to use 35 | type: choice 36 | options: 37 | - v3 nightly 38 | - v2 nightly 39 | - v2 latest 40 | default: v2 latest 41 | repo: 42 | description: nuxt repository to use 43 | required: true 44 | type: string 45 | default: nuxt/nuxt 46 | repository_dispatch: 47 | types: [ecosystem-ci] 48 | jobs: 49 | test-ecosystem: 50 | timeout-minutes: 30 51 | runs-on: ubuntu-latest 52 | strategy: 53 | matrix: 54 | suite: 55 | - starter 56 | - content 57 | - ui 58 | - image 59 | - pinia 60 | - examples 61 | - example-layers-monorepo 62 | - bridge 63 | - nuxt-com 64 | - vite-pwa 65 | - docus 66 | - og-image 67 | - histoire 68 | - elk 69 | - devtools 70 | - fonts 71 | - scripts 72 | - icon 73 | - cli 74 | - test-utils 75 | - module-builder 76 | - sanity-module 77 | - tailwindcss 78 | - sitemap 79 | - i18n-module 80 | # - werewolves-assistant 81 | - antoine-zanardi-portfolio 82 | - storybook 83 | fail-fast: false 84 | steps: 85 | - uses: actions/checkout@v4 86 | - uses: actions/setup-node@v4 87 | with: 88 | node-version: 20 89 | id: setup-node 90 | - run: npm i -g --force corepack && corepack enable 91 | - run: pnpm --version 92 | - run: pnpm i --frozen-lockfile 93 | - run: >- 94 | pnpm tsx ecosystem-ci.ts 95 | --${{ inputs.refType || github.event.client_payload.refType || 'branch' }} ${{ inputs.ref || github.event.client_payload.ref || '3.x' }} 96 | --repo ${{ inputs.repo || github.event.client_payload.repo || 'nuxt/nuxt' }} 97 | ${{ matrix.suite }} 98 | id: ecosystem-ci-run 99 | env: 100 | NUXT_UI_PRO_TOKEN: ${{ secrets.NUXT_UI_PRO_TOKEN }} 101 | NUXT_UI_PRO_LICENSE: ${{ secrets.NUXT_UI_PRO_TOKEN }} 102 | NITRO_VERSION: ${{ inputs.nitro || 'v2 latest' }} 103 | 104 | - if: always() 105 | run: pnpm tsx discord-webhook.ts 106 | env: 107 | WORKFLOW_NAME: ci 108 | REF_TYPE: ${{ inputs.refType || github.event.client_payload.refType || 'branch' }} 109 | REF: ${{ inputs.ref || github.event.client_payload.ref || '3.x' }} 110 | REPO: ${{ inputs.repo || github.event.client_payload.repo || 'nuxt/nuxt' }} 111 | SUITE: ${{ matrix.suite }} 112 | STATUS: ${{ job.status }} 113 | DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} 114 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 115 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .DS_Store? 3 | node_modules 4 | nuxt 5 | workspace 6 | .pnpm-debug.log 7 | .idea 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | strict-peer-dependencies = false 3 | package-manager-strict = false 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @danielroe 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present, Vite contributors 4 | Copyright (c) 2021-present, Nuxt contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nuxt-ecosystem-ci 2 | 3 | This repository is used to run integration tests for Nuxt ecosystem projects 4 | 5 | ## via github workflow 6 | 7 | ### scheduled 8 | 9 | Workflows are scheduled to run automatically every Monday, Wednesday and Friday 10 | 11 | ### manually 12 | 13 | - open [workflow](../../actions/workflows/ecosystem-ci-selected.yml) 14 | - click 'Run workflow' button on top right of the list 15 | - select suite to run in dropdown 16 | - start workflow 17 | 18 | ## via shell script 19 | 20 | - clone this repo 21 | - run `pnpm i` 22 | - run `pnpm test` to run all suites 23 | - or `pnpm test ` to select a suite 24 | - or `tsx ecosystem-ci.ts` 25 | 26 | You can pass `--tag v3.4.0-beta.1`, `--branch somebranch` or `--commit abcd1234` option to select a specific nuxt version to build. 27 | If you pass `--release 3.4.1`, Nuxt build will be skipped and Nuxt is fetched from the registry instead 28 | 29 | The repositories are checked out into `workspace` subdirectory as shallow clones 30 | 31 | ## via comment on PR 32 | 33 | - comment `/ecosystem-ci run` on a PR 34 | - or `/ecosystem-ci run ` to select a suite 35 | 36 | Users with triage permission to nuxt/nuxt repository can only use this. 37 | 38 | See [docs/pr-comment-setup.md](./docs/pr-comment-setup.md) for how to setup this feature. 39 | 40 | # how to add a new integration test 41 | 42 | - check out the existing [tests](./tests) and add one yourself. Thanks to some utilities it is really easy 43 | - once you are confident the suite works, add it to the lists of suites in the [workflows](../../actions/) 44 | 45 | # reporting results 46 | 47 | ## Discord 48 | 49 | Results are posted automatically to `#ecosystem-ci` on [Nuxt discord](https://discord.nuxtjs.org/) 50 | 51 | ### on your own server 52 | 53 | - Go to `Server settings > Integrations > Webhooks` and click `New Webhook` 54 | - Give it a name, icon and a channel to post to 55 | - copy the webhook url 56 | - get in touch with admins of this repo so they can add the webhook 57 | 58 | #### how to add a discord webhook here 59 | 60 | - Go to `/settings/secrets/actions` and click on `New repository secret` 61 | - set `Name` as `DISCORD_WEBHOOK_URL` 62 | - paste the discord webhook url you copied from above into `Value` 63 | - Click `Add secret` 64 | -------------------------------------------------------------------------------- /discord-webhook.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process' 2 | import { fetch } from 'node-fetch-native' 3 | import { getPermanentRef, setupEnvironment } from './utils.ts' 4 | 5 | type RefType = 'branch' | 'tag' | 'commit' | 'release' 6 | type Status = 'success' | 'failure' | 'cancelled' 7 | interface Env { 8 | WORKFLOW_NAME?: string 9 | REF_TYPE?: RefType 10 | REF?: string 11 | REPO?: string 12 | SUITE?: string 13 | STATUS?: Status 14 | DISCORD_WEBHOOK_URL?: string 15 | } 16 | 17 | const statusConfig = { 18 | success: { 19 | color: Number.parseInt('57ab5a', 16), 20 | emoji: ':white_check_mark:', 21 | }, 22 | failure: { 23 | color: Number.parseInt('e5534b', 16), 24 | emoji: ':x:', 25 | }, 26 | cancelled: { 27 | color: Number.parseInt('768390', 16), 28 | emoji: ':stop_button:', 29 | }, 30 | } 31 | 32 | async function run() { 33 | if (!process.env.GITHUB_ACTIONS) { 34 | throw new Error('This script can only run on GitHub Actions.') 35 | } 36 | if (!process.env.DISCORD_WEBHOOK_URL) { 37 | console.warn( 38 | 'Skipped beacuse process.env.DISCORD_WEBHOOK_URL was empty or didn\'t exist', 39 | ) 40 | return 41 | } 42 | if (!process.env.GITHUB_TOKEN) { 43 | console.warn( 44 | 'Not using a token because process.env.GITHUB_TOKEN was empty or didn\'t exist', 45 | ) 46 | } 47 | 48 | const env = process.env as Env 49 | 50 | assertEnv('WORKFLOW_NAME', env.WORKFLOW_NAME) 51 | assertEnv('REF_TYPE', env.REF_TYPE) 52 | assertEnv('REF', env.REF) 53 | assertEnv('REPO', env.REPO) 54 | assertEnv('SUITE', env.SUITE) 55 | assertEnv('STATUS', env.STATUS) 56 | assertEnv('DISCORD_WEBHOOK_URL', env.DISCORD_WEBHOOK_URL) 57 | 58 | await setupEnvironment() 59 | 60 | const refType = env.REF_TYPE 61 | // nuxt repo is not cloned when release 62 | const permRef = refType === 'release' ? undefined : await getPermanentRef() 63 | 64 | const targetText = createTargetText(refType, env.REF, permRef, env.REPO) 65 | 66 | const webhookContent = { 67 | username: `nuxt-ecosystem-ci (${env.WORKFLOW_NAME})`, 68 | avatar_url: 'https://github.com/nuxt.png', 69 | embeds: [ 70 | { 71 | title: `${statusConfig[env.STATUS].emoji} ${env.SUITE}`, 72 | description: await createDescription(env.SUITE, targetText), 73 | color: statusConfig[env.STATUS].color, 74 | }, 75 | ], 76 | } 77 | 78 | const res = await fetch(env.DISCORD_WEBHOOK_URL, { 79 | method: 'POST', 80 | headers: { 81 | 'Content-Type': 'application/json', 82 | }, 83 | body: JSON.stringify(webhookContent), 84 | }) 85 | if (res.ok) { 86 | console.log('Sent Webhook') 87 | } 88 | else { 89 | console.error(`Webhook failed ${res.status}:`, await res.text()) 90 | } 91 | } 92 | 93 | function assertEnv( 94 | name: string, 95 | value: T, 96 | ): asserts value is Exclude { 97 | if (!value) { 98 | throw new Error(`process.env.${name} is empty or does not exist.`) 99 | } 100 | } 101 | 102 | async function createRunUrl(suite: string) { 103 | const result = await fetchJobs() 104 | if (!result) { 105 | return undefined 106 | } 107 | 108 | if (result.total_count <= 0) { 109 | console.warn('total_count was 0') 110 | return undefined 111 | } 112 | 113 | const job = result.jobs.find(job => job.name === process.env.GITHUB_JOB) 114 | if (job) { 115 | return job.html_url 116 | } 117 | 118 | // when matrix 119 | const jobM = result.jobs.find( 120 | job => job.name === `${process.env.GITHUB_JOB} (${suite})`, 121 | ) 122 | return jobM?.html_url 123 | } 124 | 125 | interface GitHubActionsJob { 126 | name: string 127 | html_url: string 128 | } 129 | 130 | async function fetchJobs() { 131 | const url = `${process.env.GITHUB_API_URL}/repos/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}/jobs` 132 | const res = await fetch(url, { 133 | headers: { 134 | Accept: 'application/vnd.github.v3+json', 135 | ...(process.env.GITHUB_TOKEN 136 | ? { 137 | Authorization: `token ${process.env.GITHUB_TOKEN}`, 138 | 139 | } 140 | : undefined), 141 | }, 142 | }) 143 | if (!res.ok) { 144 | console.warn( 145 | `Failed to fetch jobs (${res.status} ${res.statusText}): ${res.text()}`, 146 | ) 147 | return null 148 | } 149 | 150 | const result = await res.json() 151 | return result as { 152 | total_count: number 153 | jobs: GitHubActionsJob[] 154 | } 155 | } 156 | 157 | async function createDescription(suite: string, targetText: string) { 158 | const runUrl = await createRunUrl(suite) 159 | const open = runUrl === undefined ? 'Null' : `[Open](${runUrl})` 160 | 161 | return ` 162 | :scroll:\u00A0\u00A0${open}\u3000\u3000:zap:\u00A0\u00A0${targetText} 163 | `.trim() 164 | } 165 | 166 | function createTargetText( 167 | refType: RefType, 168 | ref: string, 169 | permRef: string | undefined, 170 | repo: string, 171 | ) { 172 | const repoText = repo !== 'nuxt/nuxt' ? `${repo}:` : '' 173 | if (refType === 'branch') { 174 | const link = `https://github.com/${repo}/commits/${permRef || ref}` 175 | return `[${repoText}${ref} (${permRef || 'unknown'})](${link})` 176 | } 177 | 178 | const refTypeText = refType === 'release' ? ' (release)' : '' 179 | const link = `https://github.com/${repo}/commits/${ref}` 180 | return `[${repoText}${ref}${refTypeText}](${link})` 181 | } 182 | 183 | run().catch((e) => { 184 | console.error('Error sending webhook:', e) 185 | }) 186 | -------------------------------------------------------------------------------- /docs/github_app_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt/ecosystem-ci/ed222daf3bb1e9a51868ed415330385f02cb11e9/docs/github_app_id.png -------------------------------------------------------------------------------- /docs/github_app_private_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt/ecosystem-ci/ed222daf3bb1e9a51868ed415330385f02cb11e9/docs/github_app_private_key.png -------------------------------------------------------------------------------- /docs/pr-comment-setup.md: -------------------------------------------------------------------------------- 1 | # Setting up "PR comment trigger" feature 2 | 3 | ## (1) Create a GitHub App 4 | 5 | 1. [Create a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app). Webhook is not needed. The following permissions are required: 6 | - Metadata: Read only 7 | - Actions: Read and Write 8 | - Issues: Read and Write 9 | - Pull requests: Read and Write 10 | 1. Install that App to the organization/user. Give that App access to nuxt/nuxt and nuxt/ecosystem-ci. 11 | 1. Check the App ID. It's written on `https://github.com/settings/apps/`. This is used later. 12 | ![GitHub App ID](github_app_id.png) 13 | 1. Generate a private key. It can be generated on the same page with the App ID. The key will be downloaded when you generate it. 14 | ![GitHub App private key](github_app_private_key.png) 15 | 16 | ## (2) Adding secrets to nuxt/nuxt and nuxt/ecosystem-ci 17 | 18 | - nuxt/nuxt 19 | - `ECOSYSTEM_CI_GITHUB_APP_ID`: ID of the created GitHub App 20 | - `ECOSYSTEM_CI_GITHUB_APP_PRIVATE_KEY`: the content of the private key of the created GitHub App 21 | - nuxt/ecosystem-ci 22 | - `PR_GITHUB_APP_ID`: ID of the created GitHub App 23 | - `PR_GITHUB_APP_PRIVATE_KEY`: the content of the private key of the created GitHub App 24 | 25 | ## (3) Adding workflows to nuxt/nuxt 26 | 27 | Add [this workflow](https://github.com/nuxt/nuxt/blob/main/.github/workflows/ecosystem-ci-trigger.yml). 28 | -------------------------------------------------------------------------------- /ecosystem-ci.ts: -------------------------------------------------------------------------------- 1 | import type { CommandOptions, RunOptions } from './types.d.ts' 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | import process from 'node:process' 5 | 6 | import { cac } from 'cac' 7 | import { 8 | bisectNuxt, 9 | buildNuxt, 10 | getNuxtNightlyVersion, 11 | parseMajorVersion, 12 | parseNuxtMajor, 13 | setupEnvironment, 14 | setupNuxtRepo, 15 | } from './utils.ts' 16 | 17 | const cli = cac() 18 | cli 19 | .command('[...suites]', 'build nuxt and run selected suites') 20 | .option('--verify', 'verify checkouts by running tests', { default: false }) 21 | .option('--repo ', 'nuxt repository to use', { default: 'nuxt/nuxt' }) 22 | .option('--branch ', 'nuxt branch to use', { default: 'main' }) 23 | .option('--tag ', 'nuxt tag to use') 24 | .option('--commit ', 'nuxt commit sha to use') 25 | .option('--release ', 'nuxt release to use from npm registry') 26 | .action(async (suites, options: CommandOptions) => { 27 | const { root, nuxtPath, workspace } = await setupEnvironment() 28 | const suitesToRun = getSuitesToRun(suites, root) 29 | let nuxtMajor 30 | if (!options.release) { 31 | await setupNuxtRepo(options) 32 | const nightly = await getNuxtNightlyVersion() 33 | if (!nightly || options.repo === 'nuxt/nuxt') { 34 | await buildNuxt({ verify: options.verify }) 35 | } 36 | else { 37 | options.nightly = nightly 38 | } 39 | nuxtMajor = parseNuxtMajor(nuxtPath) 40 | } 41 | else { 42 | nuxtMajor = parseMajorVersion(options.release) 43 | } 44 | const runOptions: RunOptions = { 45 | root, 46 | nuxtPath, 47 | nuxtMajor, 48 | workspace, 49 | release: options.release, 50 | nightly: options.nightly, 51 | verify: options.verify, 52 | skipGit: false, 53 | } 54 | for (const suite of suitesToRun) { 55 | await run(suite, runOptions) 56 | } 57 | }) 58 | 59 | cli 60 | .command('build-nuxt', 'build nuxt only') 61 | .option('--verify', 'verify nuxt checkout by running tests', { 62 | default: false, 63 | }) 64 | .option('--repo ', 'nuxt repository to use', { default: 'nuxt/nuxt' }) 65 | .option('--branch ', 'nuxt branch to use', { default: 'main' }) 66 | .option('--tag ', 'nuxt tag to use') 67 | .option('--commit ', 'nuxt commit sha to use') 68 | .action(async (options: CommandOptions) => { 69 | await setupEnvironment() 70 | await setupNuxtRepo(options) 71 | await buildNuxt({ verify: options.verify }) 72 | }) 73 | 74 | cli 75 | .command('run-suites [...suites]', 'run single suite with pre-built nuxt') 76 | .option( 77 | '--verify', 78 | 'verify checkout by running tests before using local nuxt', 79 | { default: false }, 80 | ) 81 | .option('--repo ', 'nuxt repository to use', { default: 'nuxt/nuxt' }) 82 | .option('--release ', 'nuxt release to use from npm registry') 83 | .action(async (suites, options: CommandOptions) => { 84 | const { root, nuxtPath, workspace } = await setupEnvironment() 85 | const suitesToRun = getSuitesToRun(suites, root) 86 | const runOptions: RunOptions = { 87 | ...options, 88 | root, 89 | nuxtPath, 90 | nuxtMajor: parseNuxtMajor(nuxtPath), 91 | workspace, 92 | } 93 | for (const suite of suitesToRun) { 94 | await run(suite, runOptions) 95 | } 96 | }) 97 | 98 | cli 99 | .command( 100 | 'bisect [...suites]', 101 | 'use git bisect to find a commit in nuxt that broke suites', 102 | ) 103 | .option('--good ', 'last known good ref, e.g. a previous tag. REQUIRED!') 104 | .option('--verify', 'verify checkouts by running tests', { default: false }) 105 | .option('--repo ', 'nuxt repository to use', { default: 'nuxt/nuxt' }) 106 | .option('--branch ', 'nuxt branch to use', { default: 'main' }) 107 | .option('--tag ', 'nuxt tag to use') 108 | .option('--commit ', 'nuxt commit sha to use') 109 | .action(async (suites, options: CommandOptions & { good: string }) => { 110 | if (!options.good) { 111 | console.log( 112 | 'you have to specify a known good version with `--good `', 113 | ) 114 | process.exit(1) 115 | } 116 | const { root, nuxtPath, workspace } = await setupEnvironment() 117 | const suitesToRun = getSuitesToRun(suites, root) 118 | let isFirstRun = true 119 | const { verify } = options 120 | const runSuite = async () => { 121 | try { 122 | const nightly = await getNuxtNightlyVersion() 123 | if (!nightly) { 124 | await buildNuxt({ verify: isFirstRun && verify }) 125 | } 126 | else { 127 | options.nightly = nightly 128 | } 129 | for (const suite of suitesToRun) { 130 | await run(suite, { 131 | verify: !!(isFirstRun && verify), 132 | skipGit: !isFirstRun, 133 | root, 134 | nuxtPath, 135 | nuxtMajor: parseNuxtMajor(nuxtPath), 136 | workspace, 137 | }) 138 | } 139 | isFirstRun = false 140 | return null 141 | } 142 | catch (e) { 143 | return e 144 | } 145 | } 146 | await setupNuxtRepo({ ...options, shallow: false }) 147 | const initialError = await runSuite() 148 | if (initialError) { 149 | await bisectNuxt(options.good, runSuite) 150 | } 151 | else { 152 | console.log(`no errors for starting commit, cannot bisect`) 153 | } 154 | }) 155 | cli.help() 156 | cli.parse() 157 | 158 | async function run(suite: string, options: RunOptions) { 159 | const { test } = await import(`./tests/${suite}.ts`) 160 | await test({ 161 | ...options, 162 | workspace: path.resolve(options.workspace, suite), 163 | }) 164 | } 165 | 166 | function getSuitesToRun(suites: string[], root: string) { 167 | let suitesToRun: string[] = suites 168 | const availableSuites: string[] = fs 169 | .readdirSync(path.join(root, 'tests')) 170 | .filter((f: string) => !f.startsWith('_') && f.endsWith('.ts')) 171 | .map((f: string) => f.slice(0, -3)) 172 | availableSuites.sort() 173 | if (suitesToRun.length === 0) { 174 | suitesToRun = availableSuites 175 | } 176 | else { 177 | const invalidSuites = suitesToRun.filter( 178 | x => !x.startsWith('_') && !availableSuites.includes(x), 179 | ) 180 | if (invalidSuites.length) { 181 | console.log(`invalid suite(s): ${invalidSuites.join(', ')}`) 182 | console.log(`available suites: ${availableSuites.join(', ')}`) 183 | process.exit(1) 184 | } 185 | } 186 | return suitesToRun 187 | } 188 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // eslint-disable-next-line node/no-unpublished-import 3 | import antfu from '@antfu/eslint-config' 4 | // eslint-disable-next-line node/no-unpublished-import 5 | import pluginN from 'eslint-plugin-n' 6 | 7 | export default antfu({ 8 | typescript: true, 9 | }) 10 | .append(pluginN.configs['flat/recommended'], { 11 | plugins: [pluginN], 12 | rules: { 13 | 'eqeqeq': ['warn', 'always', { null: 'never' }], 14 | 'no-debugger': ['error'], 15 | 'no-console': 'off', 16 | 'no-empty': ['warn', { allowEmptyCatch: true }], 17 | 'no-process-exit': 'off', 18 | 'no-useless-escape': 'off', 19 | 'prefer-const': [ 20 | 'warn', 21 | { 22 | destructuring: 'all', 23 | }, 24 | ], 25 | 'n/no-missing-import': 'off', // doesn't like ts imports 26 | 'n/no-process-exit': 'off', 27 | '@typescript-eslint/no-explicit-any': 'off', // we use any in some places 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-ecosystem-ci", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "packageManager": "pnpm@10.5.2", 6 | "description": "Nuxt Ecosystem CI", 7 | "license": "MIT", 8 | "homepage": "https://github.com/nuxt/ecosystem-ci#readme", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/nuxt/ecosystem-ci.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/nuxt/ecosystem-ci/issues" 15 | }, 16 | "engines": { 17 | "node": ">=18" 18 | }, 19 | "scripts": { 20 | "prepare": "pnpm simple-git-hooks", 21 | "lint": "eslint .", 22 | "lint:fix": "pnpm lint --fix", 23 | "typecheck": "tsc", 24 | "test:self": "tsx ecosystem-ci.ts _selftest", 25 | "test": "tsx ecosystem-ci.ts", 26 | "bisect": "tsx ecosystem-ci.ts bisect" 27 | }, 28 | "dependencies": { 29 | "@actions/core": "^1.11.1", 30 | "cac": "^6.7.14", 31 | "changelogen": "^0.5.7", 32 | "execa": "^9.5.2", 33 | "node-fetch-native": "^1.6.6", 34 | "ofetch": "^1.4.1", 35 | "std-env": "^3.8.0" 36 | }, 37 | "devDependencies": { 38 | "@antfu/eslint-config": "^4.3.0", 39 | "@antfu/ni": "^23.3.1", 40 | "@types/node": "^22.13.5", 41 | "@types/semver": "^7.5.8", 42 | "@typescript-eslint/eslint-plugin": "^8.25.0", 43 | "@typescript-eslint/parser": "^8.25.0", 44 | "eslint": "^9.21.0", 45 | "eslint-define-config": "^2.1.0", 46 | "eslint-plugin-n": "^17.15.1", 47 | "lint-staged": "^15.4.3", 48 | "semver": "^7.7.1", 49 | "simple-git-hooks": "^2.11.1", 50 | "tsx": "^4.19.3", 51 | "typescript": "^5.8.2" 52 | }, 53 | "simple-git-hooks": { 54 | "pre-commit": "npx lint-staged --concurrent false" 55 | }, 56 | "lint-staged": { 57 | "*.{ts,js,json,md,yaml}": [ 58 | "eslint --fix" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>nuxt/renovate-config-nuxt", 5 | "schedule:weekly", 6 | "group:allNonMajor" 7 | ], 8 | "packageRules": [ 9 | { 10 | "depTypeList": ["peerDependencies", "engines"], 11 | "enabled": false 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tests/_selftest.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | import { runInRepo } from '../utils.ts' 5 | 6 | export async function test(options: RunOptions) { 7 | await runInRepo({ 8 | ...options, 9 | repo: 'nuxt/ecosystem-ci', 10 | build: async () => { 11 | const dir = path.resolve(options.workspace, 'ecosystem-ci') 12 | const pkgFile = path.join(dir, 'package.json') 13 | const pkg = JSON.parse(await fs.promises.readFile(pkgFile, 'utf-8')) 14 | if (pkg.name !== 'nuxt-ecosystem-ci') { 15 | throw new Error( 16 | `invalid checkout, expected package.json with "name":"nuxt-ecosystem-ci" in ${dir}`, 17 | ) 18 | } 19 | pkg.scripts.selftestscript 20 | = '[ -d ../../nuxt/packages/nuxt/dist ] || (echo \'nuxt build failed\' && exit 1)' 21 | await fs.promises.writeFile( 22 | pkgFile, 23 | JSON.stringify(pkg, null, 2), 24 | 'utf-8', 25 | ) 26 | }, 27 | test: 'pnpm run selftestscript', 28 | verify: false, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /tests/antoine-zanardi-portfolio.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'antoinezanardi/antoinezanardi.fr', 8 | branch: 'master', 9 | build: ['build'], 10 | test: [ 11 | 'test:unit:cov', 12 | 'test:cucumber:prepare', 13 | 'test:cucumber', 14 | ], 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /tests/bridge.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/bridge', 8 | build: ['pnpm playwright-core install chromium', 'dev:prepare', 'build'], 9 | test: [ 10 | 'test:fixtures', 11 | 'test:fixtures:dev', 12 | 'test:fixtures:webpack', 13 | 'test:fixtures:webpack:dev', 14 | ], 15 | overrides: { 16 | 'nuxt': '^2.17.1', 17 | 'vue': '^2.7.14', 18 | 'vue-router': false, 19 | '@unhead/vue': false, 20 | '@vue/compiler-sfc': '^2.7.14', 21 | '@nuxt/webpack-builder': false, 22 | '@nuxt/vite-builder': false, 23 | }, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /tests/cli.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/cli', 8 | build: ['build'], 9 | test: ['test:dist', 'test:unit'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/content.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/content', 8 | build: ['dev:prepare', 'prepack'], 9 | test: ['test'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/devtools.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/devtools', 8 | build: [], 9 | test: ['build'], 10 | overrides: { 11 | esbuild: 'latest', 12 | }, 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tests/docus.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt-themes/docus', 8 | build: ['prepare'], 9 | test: ['generate'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/elk.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'elk-zone/elk', 8 | build: ['pnpm nuxi prepare'], 9 | test: ['test', 'test:typecheck'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/example-layers-monorepo.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/example-layers-monorepo', 8 | build: ['prepare'], 9 | test: ['pnpm run -r typecheck'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/examples.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/examples', 8 | build: [], 9 | test: ['build'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/fonts.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.js' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/fonts', 8 | build: ['dev:prepare'], 9 | test: ['test'], 10 | overrides: { 11 | esbuild: 'latest', 12 | }, 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tests/histoire.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'histoire-dev/histoire', 8 | build: ['build', 'pnpm --filter histoire-example-nuxt3 run story:build'], 9 | test: ['pnpm --filter histoire-example-nuxt3 run ci'], 10 | overrides: { 11 | 'rollup': 'latest', 12 | '@nuxtjs/tailwindcss': 'latest', 13 | 'jiti': 'latest', 14 | }, 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /tests/i18n-module.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | shallow: false, 8 | branch: 'next', 9 | beforeTest: 'pnpm playwright-core install chromium', 10 | repo: 'nuxt-modules/i18n', 11 | build: ['build'], 12 | test: ['test'], 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tests/icon.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.js' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/icon', 8 | build: ['dev:prepare', 'build'], 9 | test: ['typecheck', 'test'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/image.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/image', 8 | beforeTest: 'pnpm playwright-core install chromium', 9 | build: ['dev:prepare', 'build'], 10 | test: ['test', 'test:types'], 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/module-builder.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/module-builder', 8 | build: ['pnpm -r dev:prepare'], 9 | test: ['test'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/nuxt-com.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/nuxt.com', 8 | build: ['pnpm nuxi prepare'], 9 | test: ['test'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/og-image.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt-modules/og-image', 8 | branch: 'main', 9 | build: ['pnpm playwright-core install chromium', 'build'], 10 | test: 'test', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/pinia.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | branch: 'v2', 8 | repo: 'vuejs/pinia', 9 | build: ['pnpm run -r dev:prepare', 'build'], 10 | test: ['pnpm vitest packages/nuxt'], 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/sanity-module.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt-modules/sanity', 8 | build: ['dev:prepare', 'build'], 9 | test: ['test'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/scripts.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.js' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/scripts', 8 | beforeTest: 'pnpm playwright-core install chromium', 9 | build: ['dev:prepare'], 10 | test: ['test', 'build', 'typecheck'], 11 | overrides: { 12 | esbuild: 'latest', 13 | }, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /tests/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt-modules/sitemap', 8 | branch: 'main', 9 | build: ['build'], 10 | test: 'test', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/starter.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/starter', 8 | branch: 'v3', 9 | build: 'build', 10 | test: [], 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/storybook.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt-modules/storybook', 8 | build: ['dev:prepare', 'build', 'dev:build', 'example:showcase:build'], 9 | test: ['test'], 10 | overrides: { 11 | vite: false, 12 | }, 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /tests/tailwindcss.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt-modules/tailwindcss', 8 | branch: 'main', 9 | build: ['dev:prepare', 'build'], 10 | test: ['test'], 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/test-utils.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'nuxt/test-utils', 8 | build: ['pnpm playwright-core install chromium', 'dev:prepare', 'prepack'], 9 | test: ['test:types', 'test:examples'], 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /tests/ui.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | branch: 'v3', 8 | repo: 'nuxt/ui', 9 | build: ['dev:prepare'], 10 | test: ['typecheck', 'test', 'build'], 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /tests/vite-pwa.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.d.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'vite-pwa/nuxt', 8 | branch: 'main', 9 | beforeTest: 'pnpm playwright install chromium', 10 | build: ['dev:prepare', 'prepack'], 11 | test: 'test', 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /tests/werewolves-assistant.ts: -------------------------------------------------------------------------------- 1 | import type { RunOptions } from '../types.ts' 2 | import { runInRepo } from '../utils.ts' 3 | 4 | export async function test(options: RunOptions) { 5 | await runInRepo({ 6 | ...options, 7 | repo: 'antoinezanardi/werewolves-assistant-web-next', 8 | build: ['build'], 9 | test: [ 10 | 'test:unit', 11 | ], 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext"], 5 | "module": "nodenext", 6 | "moduleResolution": "nodenext", 7 | "allowImportingTsExtensions": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noUnusedLocals": true, 11 | "useUnknownInCatchVariables": false, 12 | "declaration": true, 13 | "noEmit": true, 14 | "sourceMap": true, 15 | "allowSyntheticDefaultImports": true, 16 | "esModuleInterop": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": ["./**/*.ts", "eslint.config.js"], 20 | "exclude": ["**/node_modules/**", "./workspace/**"] 21 | } 22 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-unpublished-import 2 | import type { AGENTS } from '@antfu/ni' 3 | 4 | export interface EnvironmentData { 5 | root: string 6 | workspace: string 7 | nuxtPath: string 8 | cwd: string 9 | env: ProcessEnv 10 | } 11 | 12 | export interface RunOptions { 13 | workspace: string 14 | root: string 15 | nuxtPath: string 16 | nuxtMajor: number 17 | verify?: boolean 18 | skipGit?: boolean 19 | release?: string 20 | nightly?: string 21 | agent?: (typeof AGENTS)[number] 22 | build?: Task | Task[] 23 | test?: Task | Task[] 24 | beforeInstall?: Task | Task[] 25 | beforeBuild?: Task | Task[] 26 | beforeTest?: Task | Task[] 27 | } 28 | 29 | type Task = string | { script: string, args?: string[] } | (() => Promise) 30 | 31 | export interface CommandOptions { 32 | suites?: string[] 33 | repo?: string 34 | branch?: string 35 | tag?: string 36 | commit?: string 37 | release?: string 38 | verify?: boolean 39 | skipGit?: boolean 40 | nightly?: string 41 | } 42 | 43 | export interface RepoOptions { 44 | repo: string 45 | dir?: string 46 | branch?: string 47 | tag?: string 48 | commit?: string 49 | shallow?: boolean 50 | overrides?: Overrides 51 | } 52 | 53 | export interface Overrides { 54 | [key: string]: string | boolean 55 | } 56 | 57 | export interface ProcessEnv { 58 | [key: string]: string | undefined 59 | } 60 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | EnvironmentData, 3 | Overrides, 4 | ProcessEnv, 5 | RepoOptions, 6 | RunOptions, 7 | Task, 8 | } from './types.d.ts' 9 | import { execSync } from 'node:child_process' 10 | import fs from 'node:fs' 11 | import path from 'node:path' 12 | import process from 'node:process' 13 | import { fileURLToPath } from 'node:url' 14 | import actionsCore from '@actions/core' 15 | // eslint-disable-next-line node/no-unpublished-import 16 | import { AGENTS, detect, getCommand, serializeCommand } from '@antfu/ni' 17 | import { execaCommand } from 'execa' 18 | import { $fetch } from 'ofetch' 19 | // eslint-disable-next-line node/no-unpublished-import 20 | import * as semver from 'semver' 21 | import { isCI } from 'std-env' 22 | 23 | const isGitHubActions = !!process.env.GITHUB_ACTIONS 24 | 25 | let nuxtPath: string 26 | let cwd: string 27 | let env: ProcessEnv 28 | 29 | function cd(dir: string) { 30 | cwd = path.resolve(cwd, dir) 31 | } 32 | 33 | export async function $(literals: TemplateStringsArray, ...values: any[]) { 34 | const cmd = literals.reduce( 35 | (result, current, i) => 36 | result + current + (values?.[i] != null ? `${values[i]}` : ''), 37 | '', 38 | ) 39 | 40 | if (isGitHubActions) { 41 | actionsCore.startGroup(`${cwd} $> ${cmd}`) 42 | } 43 | else { 44 | console.log(`${cwd} $> ${cmd}`) 45 | } 46 | 47 | const proc = execaCommand(cmd, { 48 | env, 49 | stdio: 'pipe', 50 | cwd, 51 | }) 52 | if (proc.stdin) { 53 | process.stdin.pipe(proc.stdin) 54 | } 55 | if (proc.stdout) { 56 | proc.stdout.pipe(process.stdout) 57 | } 58 | if (proc.stderr) { 59 | proc.stderr.pipe(process.stderr) 60 | } 61 | let result 62 | try { 63 | result = await proc 64 | } 65 | catch (error) { 66 | // Since we already piped the io to the parent process, we remove the duplicated 67 | // messages here so it's easier to read the error message. 68 | if (error.stdout) 69 | error.stdout = 'value removed by nuxt/ecosystem-ci' 70 | if (error.stderr) 71 | error.stderr = 'value removed by nuxt/ecosystem-ci' 72 | if (error.stdio) 73 | error.stdio = ['value removed by nuxt/ecosystem-ci'] 74 | throw error 75 | } 76 | 77 | if (isGitHubActions) { 78 | actionsCore.endGroup() 79 | } 80 | 81 | return result.stdout 82 | } 83 | 84 | export async function setupEnvironment(): Promise { 85 | const root = dirnameFrom(import.meta.url) 86 | const workspace = path.resolve(root, 'workspace') 87 | nuxtPath = path.resolve(workspace, 'nuxt') 88 | cwd = process.cwd() 89 | env = { 90 | ...process.env, 91 | CI: 'true', 92 | TURBO_FORCE: 'true', // disable turbo caching, ecosystem-ci modifies things and we don't want replays 93 | YARN_ENABLE_IMMUTABLE_INSTALLS: 'false', // to avoid errors with mutated lockfile due to overrides 94 | NODE_OPTIONS: '--max-old-space-size=6144', // GITHUB CI has 7GB max, stay below 95 | ECOSYSTEM_CI: 'true', // flag for tests, can be used to conditionally skip irrelevant tests. 96 | } 97 | initWorkspace(workspace) 98 | return { root, workspace, nuxtPath, cwd, env } 99 | } 100 | 101 | function initWorkspace(workspace: string) { 102 | if (!fs.existsSync(workspace)) { 103 | fs.mkdirSync(workspace, { recursive: true }) 104 | } 105 | const eslintrc = path.join(workspace, '.eslintrc.json') 106 | if (!fs.existsSync(eslintrc)) { 107 | fs.writeFileSync(eslintrc, '{"root":true}\n', 'utf-8') 108 | } 109 | const editorconfig = path.join(workspace, '.editorconfig') 110 | if (!fs.existsSync(editorconfig)) { 111 | fs.writeFileSync(editorconfig, 'root = true\n', 'utf-8') 112 | } 113 | const tsconfig = path.join(workspace, 'tsconfig.json') 114 | if (!fs.existsSync(tsconfig)) { 115 | fs.writeFileSync(tsconfig, '{}\n', 'utf-8') 116 | } 117 | } 118 | 119 | export async function setupRepo(options: RepoOptions) { 120 | if (options.branch == null) { 121 | options.branch = 'main' 122 | } 123 | if (options.shallow == null) { 124 | options.shallow = isCI 125 | } 126 | 127 | let { repo, commit, branch, tag, dir, shallow } = options 128 | if (!dir) { 129 | throw new Error('setupRepo must be called with options.dir') 130 | } 131 | if (!repo.includes(':')) { 132 | repo = `https://github.com/${repo}.git` 133 | } 134 | 135 | let needClone = true 136 | if (fs.existsSync(dir)) { 137 | const _cwd = cwd 138 | cd(dir) 139 | let currentClonedRepo: string | undefined 140 | try { 141 | currentClonedRepo = await $`git ls-remote --get-url` 142 | } 143 | catch { 144 | // when not a git repo 145 | } 146 | cd(_cwd) 147 | 148 | if (repo === currentClonedRepo) { 149 | needClone = false 150 | } 151 | else { 152 | fs.rmSync(dir, { recursive: true, force: true }) 153 | } 154 | } 155 | 156 | if (needClone) { 157 | await $`git -c advice.detachedHead=false clone ${ 158 | shallow ? '--depth=1 --no-tags' : '' 159 | } --branch ${tag || branch} ${repo} ${dir}` 160 | } 161 | cd(dir) 162 | await $`git clean -fdxq` 163 | await $`git fetch ${shallow ? '--depth=1 --no-tags' : '--tags'} origin ${ 164 | tag ? `tag ${tag}` : `${commit || branch}` 165 | }` 166 | if (shallow) { 167 | await $`git -c advice.detachedHead=false checkout ${ 168 | tag ? `tags/${tag}` : `${commit || branch}` 169 | }` 170 | } 171 | else { 172 | await $`git checkout ${branch}` 173 | await $`git merge FETCH_HEAD` 174 | if (tag || commit) { 175 | await $`git reset --hard ${tag || commit}` 176 | } 177 | } 178 | } 179 | 180 | function toCommand( 181 | task: Task | Task[] | void, 182 | agent: (typeof AGENTS)[number], 183 | ): ((scripts: any) => Promise) | void { 184 | return async (scripts: any) => { 185 | const tasks = Array.isArray(task) ? task : [task] 186 | for (const task of tasks) { 187 | if (task == null || task === '') { 188 | continue 189 | } 190 | else if (typeof task === 'string') { 191 | if (scripts[task] != null) { 192 | const runTaskWithAgent = getCommand(agent, 'run', [task]) 193 | await $`${serializeCommand(runTaskWithAgent)}` 194 | } 195 | else { 196 | await $`${task}` 197 | } 198 | } 199 | else if (typeof task === 'function') { 200 | await task() 201 | } 202 | else if (task?.script) { 203 | if (scripts[task.script] != null) { 204 | const runTaskWithAgent = getCommand(agent, 'run', [ 205 | task.script, 206 | ...(task.args ?? []), 207 | ]) 208 | await $`${serializeCommand(runTaskWithAgent)}` 209 | } 210 | else { 211 | throw new Error( 212 | `invalid task, script "${task.script}" does not exist in package.json`, 213 | ) 214 | } 215 | } 216 | else { 217 | throw new Error( 218 | `invalid task, expected string or function but got ${typeof task}: ${task}`, 219 | ) 220 | } 221 | } 222 | } 223 | } 224 | 225 | export async function getNuxtNightlyVersion(): Promise { 226 | const commit = execSync('git rev-parse --short HEAD', { cwd: nuxtPath }).toString('utf-8').trim().slice(0, 8) 227 | try { 228 | const { versions } = await $fetch('https://registry.npmjs.org/nuxt-nightly') as { versions: Record } 229 | return Object.keys(versions).find(v => v.endsWith(`.${commit}`)) ?? null 230 | } 231 | catch (error) { 232 | console.warn(`Failed to get Nuxt nightly version for commit ${commit}:`, error) 233 | } 234 | return null 235 | } 236 | 237 | export async function runInRepo(options: RunOptions & RepoOptions) { 238 | if (options.verify == null) { 239 | options.verify = true 240 | } 241 | if (options.skipGit == null) { 242 | options.skipGit = false 243 | } 244 | if (options.branch == null) { 245 | options.branch = 'main' 246 | } 247 | 248 | const { 249 | build, 250 | test, 251 | repo, 252 | branch, 253 | tag, 254 | commit, 255 | shallow, 256 | skipGit, 257 | verify, 258 | beforeInstall, 259 | beforeBuild, 260 | beforeTest, 261 | } = options 262 | 263 | const dir = path.resolve( 264 | options.workspace, 265 | options.dir || repo.substring(repo.lastIndexOf('/') + 1), 266 | ) 267 | 268 | if (!skipGit) { 269 | await setupRepo({ repo, dir, branch, tag, commit, shallow }) 270 | } 271 | else { 272 | cd(dir) 273 | } 274 | if (options.agent == null) { 275 | const detectedAgent = await detect({ cwd: dir, autoInstall: false }) 276 | if (detectedAgent == null) { 277 | throw new Error(`Failed to detect packagemanager in ${dir}`) 278 | } 279 | options.agent = detectedAgent 280 | } 281 | if (!AGENTS.includes(options.agent)) { 282 | throw new Error( 283 | `Invalid agent ${options.agent}. Allowed values: ${AGENTS.join(', ')}`, 284 | ) 285 | } 286 | const agent = options.agent 287 | const beforeInstallCommand = toCommand(beforeInstall, agent) 288 | const beforeBuildCommand = toCommand(beforeBuild, agent) 289 | const beforeTestCommand = toCommand(beforeTest, agent) 290 | const buildCommand = toCommand(build, agent) 291 | const testCommand = toCommand(test, agent) 292 | 293 | const pkgFile = path.join(dir, 'package.json') 294 | const pkg = JSON.parse(await fs.promises.readFile(pkgFile, 'utf-8')) 295 | 296 | await beforeInstallCommand?.(pkg.scripts) 297 | 298 | if (verify && test) { 299 | const frozenInstall = getCommand(agent, 'frozen') 300 | await $`${serializeCommand(frozenInstall)}` 301 | await beforeBuildCommand?.(pkg.scripts) 302 | await buildCommand?.(pkg.scripts) 303 | await beforeTestCommand?.(pkg.scripts) 304 | await testCommand?.(pkg.scripts) 305 | } 306 | const overrides = options.overrides || {} 307 | const ecosystemPackages = [ 308 | 'ufo', 309 | 'ofetch', 310 | 'unstorage', 311 | 'vite', 312 | 'rollup', 313 | 'consola', 314 | 'vue-router', 315 | '@vitejs/plugin-vue', 316 | '@vitejs/plugin-vue-jsx', 317 | ] 318 | for (const pkg of ecosystemPackages) { 319 | overrides[pkg] ??= await $fetch<{ version: string }>( 320 | `https://registry.npmjs.org/${pkg}/latest`, 321 | ).then(r => r.version) 322 | } 323 | if (options.release) { 324 | if (overrides.nuxt && overrides.nuxt !== options.release) { 325 | throw new Error( 326 | `conflicting overrides.nuxt=${overrides.nuxt} and --release=${options.release} config. Use either one or the other`, 327 | ) 328 | } 329 | else { 330 | overrides.nuxt = options.release 331 | } 332 | } 333 | else if (options.nightly) { 334 | overrides.nuxt ??= `npm:nuxt-nightly@${options.nightly}` 335 | overrides['@nuxt/kit'] ??= `npm:@nuxt/kit-nightly@${options.nightly}` 336 | overrides['@nuxt/schema'] ??= `npm:@nuxt/schema-nightly@${options.nightly}` 337 | overrides['@nuxt/vite-builder'] ??= `npm:@nuxt/vite-builder-nightly@${options.nightly}` 338 | overrides['@nuxt/webpack-builder'] 339 | ??= `npm:@nuxt/webpack-builder-nightly@${options.nightly}` 340 | } 341 | else { 342 | // if (pkg.name !== 'nuxi') { 343 | // overrides.nuxi ??= `npm:nuxi-nightly` 344 | // } 345 | // if (pkg.name !== '@nuxt/test-utils') { 346 | // overrides['@nuxt/test-utils'] ??= `npm:@nuxt/test-utils-nightly` 347 | // } 348 | 349 | overrides.nuxt ??= `${options.nuxtPath}/packages/nuxt` 350 | overrides['@nuxt/kit'] ??= `${options.nuxtPath}/packages/kit` 351 | overrides['@nuxt/schema'] ??= `${options.nuxtPath}/packages/schema` 352 | overrides['@nuxt/vite-builder'] ??= `${options.nuxtPath}/packages/vite` 353 | overrides['@nuxt/webpack-builder'] 354 | ??= `${options.nuxtPath}/packages/webpack` 355 | } 356 | 357 | const { resolutions, devDependencies } = JSON.parse( 358 | await fs.promises.readFile( 359 | path.join(options.nuxtPath, 'package.json'), 360 | 'utf-8', 361 | ), 362 | ) 363 | 364 | if (process.env.NITRO_VERSION === 'v3 nightly') { 365 | overrides.nitro ??= `npm:nitro-nightly@3x` 366 | overrides.nitropack ??= `npm:nitro-nightly@3x` 367 | overrides.h3 ??= `npm:h3-nightly@2.0.0-1718872656.6765a6e` 368 | } 369 | else if (process.env.NITRO_VERSION === 'v2 nightly') { 370 | overrides.nitropack ??= `npm:nitropack-nightly@latest` 371 | overrides.h3 ??= `npm:h3-nightly@latest` 372 | } 373 | else { 374 | overrides.nitropack ??= devDependencies?.nitropack || resolutions.nitropack 375 | overrides.h3 ??= devDependencies?.h3 || resolutions.h3 376 | } 377 | 378 | const vueResolution 379 | = overrides.vue === false ? false : overrides.vue || resolutions?.vue 380 | if (vueResolution) { 381 | overrides.vue ||= vueResolution 382 | overrides['@vue/compiler-sfc'] ||= vueResolution 383 | if (vueResolution.match(/^[~^]?3/)) { 384 | overrides['@vue/compiler-ssr'] ||= vueResolution 385 | overrides['@vue/runtime-dom'] ||= vueResolution 386 | overrides['@vue/server-renderer'] ||= vueResolution 387 | overrides['@vue/compiler-core'] ||= vueResolution 388 | overrides['@vue/reactivity'] ||= vueResolution 389 | overrides['@vue/shared'] ||= vueResolution 390 | overrides['@vue/compiler-dom'] ||= vueResolution 391 | overrides['@vue/runtime-core'] ||= vueResolution 392 | } 393 | } 394 | await applyPackageOverrides(dir, pkg, overrides) 395 | // TODO: temporary workaround for type failures for stricter nuxt v4 options 396 | if (options.nuxtMajor === 4) { 397 | const nuxtrc = path.join(dir, '.nuxtrc') 398 | fs.appendFileSync(nuxtrc, '\ntypescript.tsConfig.compilerOptions.noUncheckedIndexedAccess=false\n', 'utf-8') 399 | } 400 | await beforeBuildCommand?.(pkg.scripts) 401 | await buildCommand?.(pkg.scripts) 402 | if (test) { 403 | await beforeTestCommand?.(pkg.scripts) 404 | await testCommand?.(pkg.scripts) 405 | } 406 | return { dir } 407 | } 408 | 409 | export async function setupNuxtRepo(options: Partial) { 410 | const repo = options.repo || 'nuxt/nuxt' 411 | await setupRepo({ 412 | repo, 413 | dir: nuxtPath, 414 | branch: 'main', 415 | ...options, 416 | }) 417 | 418 | try { 419 | const rootPackageJsonFile = path.join(nuxtPath, 'package.json') 420 | const rootPackageJson = JSON.parse( 421 | await fs.promises.readFile(rootPackageJsonFile, 'utf-8'), 422 | ) 423 | const nuxtMonoRepoNames = ['nuxt-framework'] 424 | const { name } = rootPackageJson 425 | if (!nuxtMonoRepoNames.includes(name)) { 426 | throw new Error( 427 | `expected "name" field of ${repo}/package.json to indicate nuxt monorepo, but got ${name}.`, 428 | ) 429 | } 430 | const needsWrite = await overridePackageManagerVersion( 431 | rootPackageJson, 432 | 'pnpm', 433 | ) 434 | if (needsWrite) { 435 | fs.writeFileSync( 436 | rootPackageJsonFile, 437 | JSON.stringify(rootPackageJson, null, 2), 438 | 'utf-8', 439 | ) 440 | if (rootPackageJson.devDependencies?.pnpm) { 441 | await $`pnpm install -Dw pnpm --lockfile-only` 442 | } 443 | } 444 | } 445 | catch (e) { 446 | throw new Error(`Failed to setup nuxt repo`, { cause: e }) 447 | } 448 | } 449 | 450 | export async function getPermanentRef() { 451 | cd(nuxtPath) 452 | try { 453 | const ref = await $`git log -1 --pretty=format:%h` 454 | return ref 455 | } 456 | catch (e) { 457 | console.warn(`Failed to obtain perm ref. ${e}`) 458 | return undefined 459 | } 460 | } 461 | 462 | export async function buildNuxt({ verify = false }) { 463 | cd(nuxtPath) 464 | const frozenInstall = getCommand('pnpm', 'frozen') 465 | const runPrepare = getCommand('pnpm', 'run', ['dev:prepare']) 466 | const runBuild = getCommand('pnpm', 'run', ['build']) 467 | const runTest = getCommand('pnpm', 'run', ['test']) 468 | await $`${serializeCommand(frozenInstall)}` 469 | await $`${serializeCommand(runPrepare)}` 470 | await $`${serializeCommand(runBuild)}` 471 | if (verify) { 472 | await $`${serializeCommand(runTest)}` 473 | } 474 | } 475 | 476 | export async function bisectNuxt( 477 | good: string, 478 | runSuite: () => Promise, 479 | ) { 480 | // sometimes nuxt build modifies files in git, e.g. LICENSE.md 481 | // this would stop bisect, so to reset those changes 482 | const resetChanges = async () => $`git reset --hard HEAD` 483 | 484 | try { 485 | cd(nuxtPath) 486 | await resetChanges() 487 | await $`git bisect start` 488 | await $`git bisect bad` 489 | await $`git bisect good ${good}` 490 | let bisecting = true 491 | while (bisecting) { 492 | const commitMsg = await $`git log -1 --format=%s` 493 | const isNonCodeCommit = commitMsg.match(/^(?:release|docs)[:(]/) 494 | if (isNonCodeCommit) { 495 | await $`git bisect skip` 496 | continue // see if next commit can be skipped too 497 | } 498 | const error = await runSuite() 499 | cd(nuxtPath) 500 | await resetChanges() 501 | const bisectOut = await $`git bisect ${error ? 'bad' : 'good'}` 502 | bisecting = bisectOut.substring(0, 10).toLowerCase() === 'bisecting:' // as long as git prints 'bisecting: ' there are more revisions to test 503 | } 504 | } 505 | catch (e) { 506 | console.log('error while bisecting', e) 507 | } 508 | finally { 509 | try { 510 | cd(nuxtPath) 511 | await $`git bisect reset` 512 | } 513 | catch (e) { 514 | console.log('Error while resetting bisect', e) 515 | } 516 | } 517 | } 518 | 519 | function isLocalOverride(v: string): boolean { 520 | if (!v.includes('/') || v.startsWith('@')) { 521 | // not path-like (either a version number or a package name) 522 | return false 523 | } 524 | try { 525 | return !!fs.lstatSync(v)?.isDirectory() 526 | } 527 | catch (e) { 528 | if (e.code !== 'ENOENT') { 529 | throw e 530 | } 531 | return false 532 | } 533 | } 534 | 535 | /** 536 | * utility to override packageManager version 537 | * 538 | * @param pkg parsed package.json 539 | * @param pm package manager to override eg. `pnpm` 540 | * @returns {boolean} true if pkg was updated, caller is responsible for writing it to disk 541 | */ 542 | async function overridePackageManagerVersion( 543 | pkg: { [key: string]: any }, 544 | pm: string, 545 | ): Promise { 546 | const versionInUse = pkg.packageManager?.startsWith(`${pm}@`) 547 | ? pkg.packageManager.substring(pm.length + 1) 548 | : await $`${pm} --version` 549 | let overrideWithVersion: string | null = null 550 | if (pm === 'pnpm') { 551 | if (semver.eq(versionInUse, '7.18.0')) { 552 | // avoid bug with absolute overrides in pnpm 7.18.0 553 | overrideWithVersion = '7.18.1' 554 | } 555 | } 556 | if (overrideWithVersion) { 557 | console.warn( 558 | `detected ${pm}@${versionInUse} used in ${pkg.name}, changing pkg.packageManager and pkg.engines.${pm} to enforce use of ${pm}@${overrideWithVersion}`, 559 | ) 560 | // corepack reads this and uses pnpm @ newVersion then 561 | pkg.packageManager = `${pm}@${overrideWithVersion}` 562 | if (!pkg.engines) { 563 | pkg.engines = {} 564 | } 565 | pkg.engines[pm] = overrideWithVersion 566 | 567 | if (pkg.devDependencies?.[pm]) { 568 | // if for some reason the pm is in devDependencies, that would be a local version that'd be preferred over our forced global 569 | // so ensure it here too. 570 | pkg.devDependencies[pm] = overrideWithVersion 571 | } 572 | 573 | return true 574 | } 575 | return false 576 | } 577 | 578 | export async function applyPackageOverrides( 579 | dir: string, 580 | pkg: any, 581 | overrides: Overrides = {}, 582 | ) { 583 | const useFileProtocol = (v: string) => 584 | isLocalOverride(v) ? `file:${path.resolve(v)}` : v 585 | // remove boolean flags 586 | overrides = Object.fromEntries( 587 | Object.entries(overrides) 588 | .filter(([_key, value]) => typeof value === 'string') 589 | .map(([key, value]) => [key, useFileProtocol(value as string)]), 590 | ) 591 | await $`git clean -fdxq` // remove current install 592 | 593 | const agent = await detect({ cwd: dir, autoInstall: false }) 594 | if (!agent) { 595 | throw new Error(`failed to detect packageManager in ${dir}`) 596 | } 597 | // Remove version from agent string: 598 | // yarn@berry => yarn 599 | // pnpm@6, pnpm@7 => pnpm 600 | const pm = agent?.split('@')[0] 601 | 602 | await overridePackageManagerVersion(pkg, pm) 603 | 604 | if (pm === 'pnpm') { 605 | if (!pkg.devDependencies) { 606 | pkg.devDependencies = {} 607 | } 608 | const missingDeps = Object.fromEntries( 609 | Object.entries(overrides).filter( 610 | ([name]) => !pkg.devDependencies[name] && !pkg.dependencies?.[name], 611 | ), 612 | ) 613 | pkg.devDependencies = { 614 | ...pkg.devDependencies, 615 | ...missingDeps, // overrides must be present in devDependencies or dependencies otherwise they may not work 616 | } 617 | if (!pkg.pnpm) { 618 | pkg.pnpm = {} 619 | } 620 | if (pkg.pnpm.patchedDependencies) { 621 | for (const item of Object.keys(pkg.pnpm.patchedDependencies)) { 622 | for (const override in overrides) { 623 | if (item.startsWith(`${override}@`)) { 624 | delete pkg.pnpm.patchedDependencies[item] 625 | } 626 | } 627 | } 628 | } 629 | pkg.pnpm.overrides = { 630 | ...pkg.resolutions, 631 | ...pkg.pnpm.overrides, 632 | ...overrides, 633 | } 634 | if (pkg.resolutions) { 635 | pkg.resolutions = { 636 | ...pkg.resolutions, 637 | ...overrides, 638 | } 639 | } 640 | } 641 | else if (pm === 'yarn') { 642 | pkg.resolutions = { 643 | ...pkg.resolutions, 644 | ...overrides, 645 | } 646 | } 647 | else if (pm === 'npm') { 648 | pkg.overrides = { 649 | ...pkg.overrides, 650 | ...overrides, 651 | } 652 | // npm does not allow overriding direct dependencies, force it by updating the blocks themselves 653 | for (const [name, version] of Object.entries(overrides)) { 654 | if (pkg.dependencies?.[name]) { 655 | pkg.dependencies[name] = version 656 | } 657 | if (pkg.devDependencies?.[name]) { 658 | pkg.devDependencies[name] = version 659 | } 660 | } 661 | } 662 | else { 663 | throw new Error(`unsupported package manager detected: ${pm}`) 664 | } 665 | const pkgFile = path.join(dir, 'package.json') 666 | await fs.promises.writeFile(pkgFile, JSON.stringify(pkg, null, 2), 'utf-8') 667 | 668 | // use of `ni` command here could cause lockfile violation errors so fall back to native commands that avoid these 669 | if (pm === 'pnpm') { 670 | await $`pnpm install --prefer-frozen-lockfile --strict-peer-dependencies false` 671 | } 672 | else if (pm === 'yarn') { 673 | await $`yarn install` 674 | } 675 | else if (pm === 'npm') { 676 | await $`npm install` 677 | } 678 | } 679 | 680 | export function dirnameFrom(url: string) { 681 | return path.dirname(fileURLToPath(url)) 682 | } 683 | 684 | export function parseNuxtMajor(nuxtPath: string): number { 685 | const content = fs.readFileSync( 686 | path.join(nuxtPath, 'packages', 'nuxt', 'package.json'), 687 | 'utf-8', 688 | ) 689 | const pkg = JSON.parse(content) 690 | return parseMajorVersion(pkg.version) 691 | } 692 | 693 | export function parseMajorVersion(version: string) { 694 | return Number.parseInt(version.split('.', 1)[0], 10) 695 | } 696 | --------------------------------------------------------------------------------